手机号一键登录基本上是App的标配,Flutter在这方面也有了一些支持,本篇我们采用极光的jverify插件来实现手机号一键登录的支持。

1. 极光手机号登录插件介绍

首先,我们登录到flutter.dev搜索下jverify插件

在这里插入图片描述
可以看到说明是极光官方的插件项目。

2. 引入极光插件

首先,我们创建一个flutter项目

$ flutter create jverify_login_demo

引入的话,我们还是采用直接引入github的方式,就不用经常更新版本。在pubspec.yaml中添加:

  jverify:
    git:
      url: git://github.com/jpush/jverify-flutter-plugin.git
      ref: master

3. 到极光创建应用

登录极光官网https://www.jiguang.cn,找到创建应用的地方:

在这里插入图片描述
输入创建应用的名称,图标可以上传也可以不上传图标:
在这里插入图片描述
在产品设置里面选择“极光认证”:

在这里插入图片描述
填写包名和签名信息:

在这里插入图片描述
签名信息需要下载极光的apk到手机上获取:

在这里插入图片描述
相当于,咱们需要demo先要生成apk,然后再用极光的apk获取这个应用签名。这部分我这里描述的可能不是很清楚,不了解的同学,请搜索动哒公众号(dongda_5g),随时咨询我。

还没完,还需要设置一个RSA密钥对,后面用于解密手机号。进入应用的认证设置:

在这里插入图片描述

在这里我们设置一堆公私钥,公私钥可以在http://www.metools.info/code/c80.html在这个网站上生成:

在这里插入图片描述

4. 先获取手机号

来来来,上代码:

import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:jmessage_flutter/jmessage_flutter.dart';
import 'package:kiss_you_5g/common/user.dart';
import 'package:kiss_you_5g/ui/drawer/drawer.dart';
import 'package:kiss_you_5g/ui/head/default_header.dart';
import 'package:kiss_you_5g/ui/login/phone_login_page.dart';
import 'package:kiss_you_5g/ui/messages/home_item.dart';
import 'package:kiss_you_5g/ui/messages/horizontal_list_item.dart';
import 'package:kiss_you_5g/ui/messages/vertical_list_item.dart';
import 'package:kiss_you_5g/ui/relatives/relatives_page.dart';

JmessageFlutter jmessage = JmessageFlutter();

class MessagesPage extends StatefulWidget {
  MessagesPage({Key key}) : super(key: key);

  _MessagesPageState createState() => _MessagesPageState();
}

class _MessagesPageState extends State<MessagesPage> {
  void jumpInspect() async {
    Navigator.of(context).push(MaterialPageRoute(builder: (context) {
      return PhoneLoginPage();
    }));
  }

  Animation<double> _rotationAnimation;
  Color _fabColor = Colors.blue;

  void handleSlideAnimationChanged(Animation<double> slideAnimation) {
    setState(() {
      _rotationAnimation = slideAnimation;
    });
  }

  void handleSlideIsOpenChanged(bool isOpen) {
    setState(() {
      _fabColor = isOpen ? Colors.green : Colors.blue;
    });
  }

  SlidableController _slidableController;

  List<HomeItem> items = List<HomeItem>();

  void initConversations () async {

      await jmessage.login(username: User.singleton.mobile, password: '666666');
      // 获取conversations
      List<JMConversationInfo> conversations = await jmessage.getConversations();

      print(conversations);

      setState(() {
        if(conversations !=null && conversations.length > 0) {
          for(int i = 0; i < conversations.length; i++ ) {
            print('会话:${conversations[i].toJson()}');
            print('检查下类型');
            HomeItem item;
            if(conversations[i].latestMessage is JMImageMessage) {
              item = HomeItem(
                i,
                conversations[i].target.username,
                // 这里可能要通过自定义的格式来指定
                '[图片]',
                _getAvatarColor(i),
                conversations[i].target.nickname,
                conversations[i].target.avatarThumbPath,
              );
            } else {
              item = HomeItem(
                i,
                conversations[i].target.username,
                // 这里可能要通过自定义的格式来指定
                conversations[i].latestMessage.text,
                _getAvatarColor(i),
                conversations[i].target.nickname,
                conversations[i].target.avatarThumbPath,
              );
            }

            items.add(item);
          }
        }

      });

      _slidableController = SlidableController(
        onSlideAnimationChanged: handleSlideAnimationChanged,
        onSlideIsOpenChanged: handleSlideIsOpenChanged,
      );
  }

  @override
  void initState() {
    // TODO: implement initState
    super.initState();
    print('检查下用户信息:');
    print(User.singleton.mobile);
    if (User.singleton.mobile == null) {
      Future.delayed(Duration(microseconds: 800), jumpInspect);
    } else {
      initConversations();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      drawer: DrawerPage(),
      appBar: AppBar(
        title: Text('消息'),
        leading: Builder(
          builder: (context) {
            return IconButton(
              icon: DefaultHeader(),
              onPressed: () {
                return Scaffold.of(context).openDrawer();
              },
            );
          },
        ),
        actions: <Widget>[
          IconButton(
            icon: Icon(Icons.person),
            tooltip: '发起对话',
            onPressed: () {
              Navigator.of(context).push(MaterialPageRoute(builder: (context) {
                return RelativesPage();
              }));
            },
          ),
        ],
      ),
      body: Center(
        child: OrientationBuilder(
          builder: (context, orientation) => _buildList(
            context, 
            orientation == Orientation.portrait
              ? Axis.vertical
              : Axis.horizontal
            ),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        backgroundColor: _fabColor,
        onPressed: () {},
        child: _rotationAnimation == null
          ? Icon(Icons.add)
          : RotationTransition(
            turns: _rotationAnimation,
            child: Icon(Icons.add),
          ),
        heroTag: 'second',
      ),
    );
  }

  Widget _buildList(BuildContext context, Axis direction) {
    return ListView.builder(
      scrollDirection: direction,
      itemBuilder: (context, index) {
        final Axis slidableDirection = 
          direction == Axis.horizontal ? Axis.vertical : Axis.horizontal;
        var item = items[index];
        if(item.index < 8) {
          return _getSlidableWithLists(context, index, slidableDirection);
        } else {
          return _getSlidableWithDelegates(context, index, slidableDirection);
        }
      },
      itemCount: items.length,
    );
  }

  Widget _getSlidableWithLists(BuildContext context, int index, Axis direction) {
    final HomeItem item = items[index];
    return Slidable(
      key: Key(item.title),
      controller: _slidableController,
      direction: direction,
      dismissal: SlidableDismissal(
        child: SlidableDrawerDismissal(),
        onDismissed: (actionType) {
          _showSnackBar(
            context, 
            actionType == SlideActionType.primary
              ? 'Dismiss Archive'
              : 'Dismiss Delete'
            );
          setState(() {
           items.removeAt(index);
          });
        },
      ),
      actionPane: _getActionPane(item.index),
      actionExtentRatio: 0.25,
      child: direction == Axis.horizontal
        ? VerticalListItem(items[index])
        : HorizontalListItem(items[index]),
      actions: <Widget>[
        IconSlideAction(
          caption: 'Archive',
          color: Colors.blue,
          icon: Icons.archive,
          onTap: () => _showSnackBar(context, 'Archive'),
        ),
        IconSlideAction(
          caption: 'Share',
          color: Colors.indigo,
          icon: Icons.share,
          onTap: () => _showSnackBar(context, 'Share'),
        ),
      ],
      secondaryActions: <Widget>[
        IconSlideAction(
          caption: 'More',
          color: Colors.grey.shade200,
          icon: Icons.more_horiz,
          onTap: () => _showSnackBar(context, 'More'),
          closeOnTap: false,
        ),
        IconSlideAction(
          caption: 'Delete',
          color: Colors.red,
          icon: Icons.delete,
          onTap: () => _showSnackBar(context, 'Delete'),
        ),
      ],
    );
  }

  Widget _getSlidableWithDelegates(BuildContext context, int index, Axis direction) {
    final HomeItem item = items[index];

    return Slidable.builder(
      key: Key(item.title),
      controller: _slidableController,
      direction: direction,
      dismissal: SlidableDismissal(
        child: SlidableDrawerDismissal(),
        closeOnCanceled: true,
        onWillDismiss: (item.index != 10) 
          ? null
          : (actionType) {
            return showDialog<bool>(
              context: context,
              builder: (context) {
                return AlertDialog(
                  title: Text('Delete'),
                  content: Text('Item will be deleted'),
                  actions: <Widget>[
                    FlatButton(
                      child: Text('Cancel'),
                      onPressed: () => Navigator.of(context).pop(false),
                    ),
                    FlatButton(
                      child: Text('Ok'),
                      onPressed: () => Navigator.of(context).pop(true),
                    ),
                  ],
                );
              }
            );
          },
          onDismissed: (actionType) {
            _showSnackBar(
              context, 
              actionType == SlideActionType.primary
                ? 'Dismiss Archive'
                : 'Dismiss Delete'
            );
            setState(() {
             items.removeAt(index); 
            });
          },
      ),
      actionPane: _getActionPane(item.index),
      actionExtentRatio: 0.25,
      child: direction == Axis.horizontal
        ? VerticalListItem(items[index])
        : HorizontalListItem(items[index]),
      actionDelegate: SlideActionBuilderDelegate(
        actionCount: 2,
        builder: (context, index, animation, renderingMode) {
          if (index == 0) {
            return IconSlideAction(
              caption: 'Archive',
              color: renderingMode == SlidableRenderingMode.slide
                ? Colors.blue.withOpacity(animation.value)
                : (renderingMode == SlidableRenderingMode.dismiss
                  ? Colors.blue
                  : Colors.green
                )
              ,
              icon: Icons.archive,
              onTap: () async {
                var state = Slidable.of(context);
                var dismiss = await showDialog<bool>(
                  context: context,
                  builder: (context) {
                    return AlertDialog(
                      title: Text('Delete'),
                      content: Text('Item will be deleted'),
                      actions: <Widget>[
                        FlatButton(
                          child: Text('Cancel'),
                          onPressed: () => Navigator.of(context).pop(false),
                        ),
                        FlatButton(
                          child: Text('Ok'),
                          onPressed: () => Navigator.of(context).pop(true),
                        ),
                      ],
                    );
                  }
                );

                if(dismiss) {
                  state.dismiss();
                }
              },
            );
          } else {
            return IconSlideAction(
              caption: 'Share',
              color: renderingMode == SlidableRenderingMode.slide
                ? Colors.indigo.withOpacity(animation.value)
                : Colors.indigo,
              icon: Icons.share,
              onTap: () => _showSnackBar(context, 'Share'),
            );
          }
        }
      ),
      secondaryActionDelegate: SlideActionBuilderDelegate(
        actionCount: 2,
        builder: (context, index, animation, renderingMode) {
          if (index == 0) {
            return IconSlideAction(
              caption: 'More',
              color: renderingMode == SlidableRenderingMode.slide
                ? Colors.grey.shade200.withOpacity(animation.value)
                : Colors.grey.shade200,
              icon: Icons.more_horiz,
              onTap: () => _showSnackBar(context, 'More'),
              closeOnTap: false,
            );
          } else {
            return IconSlideAction(
              caption: 'Delete',
              color: renderingMode == SlidableRenderingMode.slide
                ? Colors.red.withOpacity(animation.value)
                : Colors.red,
              icon: Icons.delete,
              onTap: () => _showSnackBar(context, 'Delete'),
            );
          }
        }
      ),
    );
  }

  static Widget _getActionPane(int index) {
    switch (index % 4) {
      case 0:
        return SlidableBehindActionPane();
      case 1:
        return SlidableStrechActionPane();
      case 2:
        return SlidableScrollActionPane();
      case 3:
        return SlidableDrawerActionPane();
      default:
        return null;
    }
  }

  static Color _getAvatarColor(int index) {
    switch (index % 4) {
      case 0:
        return Colors.red;
      case 1:
        return Colors.green;
      case 2:
        return Colors.blue;
      case 3:
        return Colors.indigoAccent;
      default:
        return null;
    }
  }

  static String _getSubtitle(int index) {
    switch (index % 4) {
      case 0:
        return 'SlidableBehindActionPane';
      case 1:
        return 'SlidableStrechActionPane';
      case 2:
        return 'SlidableScrollActionPane';
      case 3:
        return 'SlidableDrawerActionPane';
      default:
        return null;
    }
  }

  void _showSnackBar(BuildContext context, String text) {
    Scaffold.of(context).showSnackBar(SnackBar(
      content: Text(text),
    ));
  }
}

界面截图如下:
在这里插入图片描述
注意,看日志,接口返回的信息中包含message:

W/JIGUANG-VERIFICATION(16398): [SimInfoFetcher] getActiveSimIDAPI22 . warn . No phone privilege permission
W/JIGUANG-VERIFICATION(16398): [SimInfoFetcher] getActiveSimIDUnderAPI22 . warn . null
D/JIGUANG-VERIFICATION(16398): [SimInfoFetcher] [getIdentifier] identifier = 1
D/JIGUANG-VERIFICATION(16398): [VerifyCall] code=6000 msg=Dtw+jLZPYdjQfo+RzOnMlcrN2EFpJB3LZBih6CS1x0fiKWJ4IhqNPMuHT2tggog8ZevVwl87VRi0UU6ZjvDbbf1ozb6X5YItsLy720Cf9SA250PYk2PYZbBU0pywzqW/5LZ1RkvB6vc6Kwtrl77d3W3vpCKbIZN+N4QiuixNxWqlXw8Ynl1hCuejaklp0YQRRKtw92lZ5v5WExGeGLV938QP0EuBqYABqGgd/FcqJzEjW1lM2WP60B6uzcYZYXXE7NrmTD3N+2R7G0WIsKtIFGW/ZODk0AZ9rBL1WK3hsDRXsa4fpSRScXcXuSibyBbRiXZbC8WG75OHd+f0Ug3kgQ0Rs7hORpMOJFg4gLEpQqclclCrQblwNK4/giLxEfZqT91OGrAP+JTZ/y6qYEFwC2sgLz+YRgU2YgMBzTmV3LDrbGVJ1y1g3deeT+FVE0/X detail=[CU,(100)成功]
D/| JVER | Android | -(16398): code=6000, token=Dtw+jLZPYdjQfo+RzOnMlcrN2EFpJB3LZBih6CS1x0fiKWJ4IhqNPMuHT2tggog8ZevVwl87VRi0UU6ZjvDbbf1ozb6X5YItsLy720Cf9SA250PYk2PYZbBU0pywzqW/5LZ1RkvB6vc6Kwtrl77d3W3vpCKbIZN+N4QiuixNxWqlXw8Ynl1hCuejaklp0YQRRKtw92lZ5v5WExGeGLV938QP0EuBqYABqGgd/FcqJzEjW1lM2WP60B6uzcYZYXXE7NrmTD3N+2R7G0WIsKtIFGW/ZODk0AZ9rBL1WK3hsDRXsa4fpSRScXcXuSibyBbRiXZbC8WG75OHd+f0Ug3kgQ0Rs7hORpMOJFg4gLEpQqclclCrQblwNK4/giLxEfZqT91OGrAP+JTZ/y6qYEFwC2sgLz+YRgU2YgMBzTmV3LDrbGVJ1y1g3deeT+FVE0/X ,operator=CU
I/flutter (16398): handleMethod method = onReceiveLoginAuthCallBackEvent
I/flutter (16398): 通过添加监听,获取到 loginAuthSyncApi 接口返回数据,code=6000,message = Dtw+jLZPYdjQfo+RzOnMlcrN2EFpJB3LZBih6CS1x0fiKWJ4IhqNPMuHT2tggog8ZevVwl87VRi0UU6ZjvDbbf1ozb6X5YItsLy720Cf9SA250PYk2PYZbBU0pywzqW/5LZ1RkvB6vc6Kwtrl77d3W3vpCKbIZN+N4QiuixNxWqlXw8Ynl1hCuejaklp0YQRRKtw92lZ5v5WExGeGLV938QP0EuBqYABqGgd/FcqJzEjW1lM2WP60B6uzcYZYXXE7NrmTD3N+2R7G0WIsKtIFGW/ZODk0AZ9rBL1WK3hsDRXsa4fpSRScXcXuSibyBbRiXZbC8WG75OHd+f0Ug3kgQ0Rs7hORpMOJFg4gLEpQqclclCrQblwNK4/giLxEfZqT91OGrAP+JTZ/y6qYEFwC2sgLz+YRgU2YgMBzTmV3LDrbGVJ1y1g3deeT+FVE0/X,operator = CU

获取手机号这部分,是登录之后返回的message,然后发极光接口,极光返回一个密文,再用私钥解密这个密文,最后得到手机号。

后台代码如下:

package com.ruoyi.jiguang.controller;

import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import java.security.InvalidKeyException;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;

@Controller
@RequestMapping("/jverify")
public class JVerifyController {

    // 私钥
    public static final String prikey = "MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAOgxyBywu0axkFXY" +
            "S81+7eVbqgm4SALF581lZw/JuGh95sICQuFwdzOnVYPmnaVqiZPWzhJrN31kI03w" +
            "WwBSmyCegh37NUxxFl6K8lxwIY/cn7Tkz0WXeTjVCEEF1GVuqxXV77DZKLykB5F9" +
            "zIpjeTTkaJ8kVBelJjbZqD+V7qMzAgMBAAECgYAwO9ckDpp4rv4atxnYEFv+3zHN" +
            "XximatIiWsQ0BWVnX3AGMU4PYruAYWxkQA3ThPQbX+3i6Z7XT2v+DsX4pTBQCwY6" +
            "wtyR3mhhjZxkKZl13J/VZ0+bgFaCPpXuLQu5J/aKkZxBhR2Nvl4KSMj2xf82m2OS" +
            "/lzgvosf01dWHt3XwQJBAPtWuA9uLk09cShUqmUcU1qoFpq+r6w2yZtR3IV7E/WK" +
            "mpxJqRn7D+AtGXvUUHWBWy1AVyFM4n5xjgQXbCQS0IMCQQDsgCve7acNup2eOey/" +
            "9hcflu0+zQaYOFgwRP014uLClOgi16bqyK4odbQqYAibYWfdGct95aPhi+KXhxhz" +
            "xwORAkEAwRl0Gi7NlfxBpvm9XCdyBvGjREqCj24cYJ95LHhN8lUFylNxfwt7vAEK" +
            "Vi/djRnQIikPh/8Y+Ipn0M7p/6EQ3wJBAIdOgUsG3redGAZpj4j4C5y4Jb3zYR1/" +
            "xvy+y7ujtiarOPCOPuI+tF1TkiNYVDRJkznNQz4hPxSQirA0y4mZx/ECQHIi6BUA" +
            "az8Dw9TtXd0mKpZyHTxG09yJSaxKQ8JlrCz3L1+saitvVmgW2Mdot0aaJ8RzP4Yd" +
            "O25z6sK/tLSrAZY=";

    @RequestMapping("/getPhone")
    @ResponseBody
    public String getPhone(String loginToken) throws InvalidKeyException, NoSuchPaddingException, NoSuchAlgorithmException, BadPaddingException, IllegalBlockSizeException, InvalidKeySpecException {

        String host = "https://api.verification.jpush.cn/v1/web/loginTokenVerify";
        String appKey = "2472cded1b1cdeb935fabff9";
        String Secret = "0df3b685ebd8092166905e0f";
        Map<String, Object> map = new HashMap<>();
        map.put("loginToken", loginToken);
        map.put("exID", null);

        String result = HttpRequest.post(host).header("Authorization", "Basic " + Base64.getUrlEncoder().
                encodeToString((appKey + ":" + Secret).getBytes())).
                header("Content-Type", "application/json; charset=UTF-8")
                .body(JSON.toJSONString(map))
                .execute().body();

        System.out.println("result:[{}]" + result);

        JSONObject jsonObject = JSON.parseObject(result);
        System.out.println("获取phone:{}" + JSON.toJSON(jsonObject));
        String cryptograph_phone = (String) jsonObject.get("phone");

        if (StrUtil.isBlank(cryptograph_phone)) {
            throw new RuntimeException("极光认证出错");
        }
        PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(Base64.getDecoder().decode(prikey));
        PrivateKey privateKey = KeyFactory.getInstance("RSA").generatePrivate(keySpec);

        Cipher cipher = Cipher.getInstance("RSA");
        cipher.init(Cipher.DECRYPT_MODE, privateKey);

        byte[] b = Base64.getDecoder().decode(cryptograph_phone);

        return new String(cipher.doFinal(b));
    }
}

4. 美中不足

美中不足的地方就是在WIFI情况下,不支持一键登录。通过调试发现,在WIFI打开的情况下,获取verifyEnable=false,表示wifi的情况下不支持一键登录。也可以理解,WIFI情况下,可能都没有手机卡,怎么支持呢?

我测试了下哔哩哔哩,在WIFI的情况下他们也不支持一键登录。

在这里插入图片描述

参考文档:
https://github.com/jpush/jverify-flutter-plugin
https://github.com/jpush/jverify-flutter-plugin/blob/master/documents/APIs.md
https://docs.jiguang.cn/jverification/server/rest_api/loginTokenVerify_api/

(欢迎关注动哒公众号(dongda_5g)获取最新动态,有问题请联系我。极光一键登录有蛮多坑哈。)

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐