Flutter手机号一键登录
手机号一键登录基本上是App的标配,Flutter在这方面也有了一些支持,本篇我们采用极光的jverify插件来实现手机号一键登录的支持。1. 极光手机号登录插件介绍首先,我们登录到flutter.dev搜索下jverify插件可以看到说明是极光官方的插件项目。2. 引入极光插件首先,我们创建一个flutter项目$ flutter create jverify_login_demo引入的话,我们
手机号一键登录基本上是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)获取最新动态,有问题请联系我。极光一键登录有蛮多坑哈。)
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
所有评论(0)