diff --git a/README.md b/README.md index 233ba98..edf48de 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Android / iOS / Windows / macOS / Linux。 | B50 | Best 50 成绩图,可导出 PNG(移植自 Empurple 的 `Best50ImageRenderer`) | | 风险 | 歌曲解锁、收藏品获取、旅行伙伴发放与编组(含等级)、修改总 Rating、添加舞里程、一键跑图、万花筒专区 | | 设置 | Title Server / Auth Server / 机器参数,支持配置导入导出 | +| 连接信息(实验性) | 主页导出 uid + JSESSIONID + token 的 Base64 快照,登录页粘贴即可恢复会话;内容未加密,请勿外传 | ## 开发 diff --git a/lib/config/strings.dart b/lib/config/strings.dart index 474b6e2..63f0b25 100644 --- a/lib/config/strings.dart +++ b/lib/config/strings.dart @@ -11,10 +11,8 @@ class AppStrings { // Login static const appTitle = 'Ichino'; - static const loginSubtitle = '扫描或输入二维码进行登录'; static const qrCodeToken = 'QR Code 令牌'; - static const qrHint = '粘贴二维码解析内容...'; - static const qrParsedResult = '解析结果(截取后 64 位)'; + static const qrHint = '粘贴二维码解析内容... / 粘贴连接信息'; static const uploadQR = '上传二维码'; static const login = '登录'; static const loggingIn = '登录中...'; @@ -26,6 +24,26 @@ class AppStrings { static const requestFailed = '请求失败'; static const settingsTooltip = '设置'; + // 连接信息(实验性):uid + JSESSIONID + token 的 Base64 快照 + static const experimentalBadge = '实验性'; + static const connectionShareTitle = '导出连接信息'; + static const connectionShareDesc = + '把用户 ID、JSESSIONID Cookie 与登录令牌打包成 Base64,' + '在其他设备的登录页粘贴即可恢复会话。'; + static const connectionShareSecurity = '内容未加密,拿到这串字符的人可以直接操作该账号。'; + static const connectionShareCopy = '复制到剪贴板'; + static const connectionShareCopied = '连接信息已复制到剪贴板'; + static const connectionShareEmpty = '当前会话既没有 Cookie 也没有令牌,无法导出。'; + static const restoreSession = '恢复会话'; + static const restoringSession = '恢复中...'; + static const sessionShareDetected = '已识别为连接信息(实验性功能),点击「恢复会话」直接续上这次登录。'; + static const sessionRestoreFailed = '会话恢复失败'; + static const sessionRestoreCookie = '已复用连接信息里的 JSESSIONID,没有重新登录'; + static const sessionRestoreToken = 'Cookie 不可用,已用令牌重新登录'; + static const sessionRestoredNotice = + '这是用连接信息恢复的会话:JSESSIONID 只用于读取数据,' + '票据与风险页的写入操作需要先重新登录。'; + // Home static const logoutTooltip = '退出登录'; static const titleServerNotConfigured = '未配置 Title Server'; @@ -115,8 +133,7 @@ class AppStrings { static const selectedTicket = '已选票'; static const ticketUsed = '功能票使用完成'; static const ticketCooldownSeconds = 60; - static String ticketCooldownCountdown(int remaining) => - '冷却中,剩余 $remaining 秒'; + static String ticketCooldownCountdown(int remaining) => '冷却中,剩余 $remaining 秒'; static String ticketCooldownNotice(int remaining) => '登录后需冷却 $ticketCooldownSeconds 秒,剩余 $remaining 秒后可使用功能票。'; @@ -434,14 +451,22 @@ class AppStrings { static String best50Exported(String? path) => path == null ? '图片已导出' : '图片已导出到 $path'; static String best50ExportFailed(String error) => '导出失败: $error'; - static const best50Footer = 'Generated by Ichino. UI designed by chuxuehaocai.'; + static const best50Footer = + 'Generated by Ichino. UI designed by chuxuehaocai.'; // About static const aboutTitle = 'Ichino'; static const aboutDesc = '基于 QR Code 的 maimai DX 街机网络登录客户端。'; + static const aboutBasics = '基本信息'; + static const aboutPlatforms = '支持平台'; + static const aboutPackageId = '应用标识'; + static const aboutRiskNotice = + '「风险」页会向服务器发送伪造的 UpsertUserAllApi 数据包,属于高危操作,可能封号。'; static const credits = '致谢'; static const creditBuiltWith = '构建框架'; static const creditQRDecode = 'QR 解码'; + static const creditB50 = 'B50 成绩图'; + static const creditProtocol = '协议实现参考'; static const creditIconAssets = '图标资源'; static const creditLicense = '许可证'; } diff --git a/lib/models/session_model.dart b/lib/models/session_model.dart index b4762b9..0fa0040 100644 --- a/lib/models/session_model.dart +++ b/lib/models/session_model.dart @@ -3,6 +3,20 @@ import 'package:flutter/foundation.dart'; import '../config/title_server_config.dart'; import '../services/api_service.dart'; import '../services/title_api_service.dart'; +import 'session_share.dart'; +import 'user_data.dart'; + +/// [SessionModel.restoreFromShare] 的结果。 +class SessionRestoreResult { + /// `true` 表示沿用了连接信息里的 JSESSIONID;`false` 表示用令牌重新登录, + /// 拿到了一份新 Cookie。 + final bool reusedCookie; + + /// 沿用旧会话时顺带拉到的用户数据(HomePage 直接展示,省一次请求)。 + final UserDataBean? userData; + + const SessionRestoreResult({required this.reusedCookie, this.userData}); +} class SessionModel extends ChangeNotifier { static final SessionModel instance = SessionModel._(); @@ -48,10 +62,7 @@ class SessionModel extends ChangeNotifier { return login; } - Future logoutGame({ - required int userId, - String? cookies, - }) async { + Future logoutGame({required int userId, String? cookies}) async { final login = _gameLogin; if (login == null) return; final config = TitleServerConfigHolder().config; @@ -75,6 +86,51 @@ class SessionModel extends ChangeNotifier { notifyListeners(); } + /// 实验性:导出连接信息(用户 ID + JSESSIONID + 令牌)。 + SessionShare exportConnectionInfo({ + required int userId, + required String token, + }) => SessionShare(userId: userId, token: token, cookie: _cookies); + + /// 实验性:用连接信息恢复会话。 + /// + /// 带着可用的 JSESSIONID 时沿用这个会话继续交互,不重发 `UserLoginApi`; + /// 没有 Cookie 或 Cookie 已失效,才退回去用令牌登录一次拿新 Cookie。 + Future restoreFromShare(SessionShare share) async { + final cookie = share.jsessionid; + if (cookie != null) { + final userData = await _probeCookieSession(share.userId, cookie); + if (userData != null) { + // _gameLogin 保持 null:这次没有 UserLoginApi,拿不到 loginId / + // loginDateTime,写入类功能页照旧提示「尚未登录游戏服务器」。 + notifyListeners(); + return SessionRestoreResult(reusedCookie: true, userData: userData); + } + } + + if (!share.hasToken) { + throw const TitleApiException('连接信息里的 Cookie 已失效,且没有可用于重新登录的令牌'); + } + await loginGame(userId: share.userId, token: share.token); + return const SessionRestoreResult(reusedCookie: false); + } + + /// 用导入的 Cookie 试拉一次 GetUserDataApi,会话不可用时返回 `null`。 + Future _probeCookieSession(int userId, String cookie) async { + final service = TitleApiService.fromHolder(cookies: cookie); + if (service == null) return null; + try { + final json = await service.getUserData(userId); + // 会话失效时服务器仍可能回一个不含 userData 的包,不能当成登录成功。 + if (json['userData'] is! Map) return null; + final userData = UserDataBean.fromJson(json); + updateCookies(service.cookies); + return userData; + } on TitleApiException { + return null; + } + } + void updateCookies(String? cookies) { if (cookies != null && cookies.isNotEmpty) { _cookies = cookies; diff --git a/lib/models/session_share.dart b/lib/models/session_share.dart new file mode 100644 index 0000000..0486def --- /dev/null +++ b/lib/models/session_share.dart @@ -0,0 +1,76 @@ +import 'dart:convert'; + +/// 实验性:一次登录的可搬运快照(用户 ID + JSESSIONID Cookie + 令牌), +/// 编码方式是 Base64(JSON),用于在别的设备上恢复会话、免重新扫码。 +/// +/// 没有任何加密,字符串在谁手里谁就能操作这个账号,只适合自己传递。 +class SessionShare { + static const String _kind = 'ichino.session'; + static const int _version = 1; + + final int userId; + final String token; + + /// 原始 Cookie 串,可能是 `JSESSIONID=...`,也可能带别的字段。 + final String? cookie; + + const SessionShare({required this.userId, required this.token, this.cookie}); + + /// 只取 `JSESSIONID`:传输层后续请求就靠它维持会话 + /// (见 `TitleApiService._captureCookiesFromResponse`)。 + String? get jsessionid { + final raw = cookie; + if (raw == null) return null; + for (final part in raw.split(';')) { + final pair = part.trim(); + final eq = pair.indexOf('='); + if (eq <= 0) continue; + if (pair.substring(0, eq).toUpperCase() != 'JSESSIONID') continue; + final value = pair.substring(eq + 1).trim(); + return value.isEmpty ? null : 'JSESSIONID=$value'; + } + return null; + } + + bool get hasCookie => jsessionid != null; + bool get hasToken => token.isNotEmpty; + + String encode() { + final map = { + 'kind': _kind, + 'version': _version, + 'userId': userId, + 'token': token, + 'cookie': jsessionid, + }; + return base64Encode(utf8.encode(jsonEncode(map))); + } + + /// 不是连接信息时返回 `null`——登录页靠它区分 QR 令牌和会话快照。 + static SessionShare? tryDecode(String input) { + var text = input.replaceAll(RegExp(r'\s'), ''); + if (text.length < 16) return null; + final pad = text.length % 4; + if (pad != 0) text += '=' * (4 - pad); + + try { + final decoded = jsonDecode(utf8.decode(base64Decode(text))); + if (decoded is! Map) return null; + if (decoded['kind'] != _kind) return null; + if ((decoded['version'] as num?)?.toInt() != _version) return null; + + final share = SessionShare( + userId: (decoded['userId'] as num?)?.toInt() ?? 0, + token: decoded['token'] as String? ?? '', + cookie: decoded['cookie'] as String?, + ); + // 两头都空的话这串信息什么都恢复不了。 + if (share.userId <= 0 || (!share.hasCookie && !share.hasToken)) { + return null; + } + return share; + } catch (_) { + return null; + } + } +} diff --git a/lib/pages/about_page.dart b/lib/pages/about_page.dart index 217a19d..a0f6da8 100644 --- a/lib/pages/about_page.dart +++ b/lib/pages/about_page.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import '../config/responsive.dart'; import '../config/strings.dart'; +import '../widgets/app_card.dart'; +import '../widgets/app_notice.dart'; class AboutPage extends StatelessWidget { const AboutPage({super.key}); @@ -16,16 +18,20 @@ class AboutPage extends StatelessWidget { context, child: Column( children: [ - const SizedBox(height: 32), - Icon(Icons.qr_code_scanner, size: 64, color: theme.colorScheme.primary), - const SizedBox(height: 16), + const SizedBox(height: 24), + Icon( + Icons.qr_code_scanner, + size: 56, + color: theme.colorScheme.primary, + ), + const SizedBox(height: 12), Text( AppStrings.aboutTitle, style: theme.textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.bold, ), ), - const SizedBox(height: 8), + const SizedBox(height: 6), Text( AppStrings.aboutDesc, style: theme.textTheme.bodyMedium?.copyWith( @@ -33,49 +39,63 @@ class AboutPage extends StatelessWidget { ), textAlign: TextAlign.center, ), - const SizedBox(height: 24), - Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide( - color: theme.colorScheme.outline.withValues(alpha: 0.3), - ), + const SizedBox(height: 28), + AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionTitle( + icon: Icons.info_outline, + title: AppStrings.aboutBasics, + ), + const SizedBox(height: 14), + _row(theme, AppStrings.creditBuiltWith, 'Flutter'), + _row( + theme, + AppStrings.aboutPlatforms, + 'Android · iOS · Windows · macOS · Linux', + ), + _row(theme, AppStrings.aboutPackageId, 'dev.naominet.ichino'), + _row(theme, AppStrings.creditLicense, 'MIT'), + ], ), - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - AppStrings.credits, - style: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.primary, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 12), - _creditRow(theme, AppStrings.creditBuiltWith, 'Flutter'), - _creditRow(theme, AppStrings.creditQRDecode, 'jsQR (web)'), - _creditRow(theme, AppStrings.creditIconAssets, 'assets2.lxns.net'), - _creditRow(theme, AppStrings.creditLicense, 'MIT'), - ], - ), + ), + const SizedBox(height: 12), + AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionTitle( + icon: Icons.group_outlined, + title: AppStrings.credits, + ), + const SizedBox(height: 14), + _row(theme, AppStrings.creditProtocol, 'empurple · eaquira'), + _row(theme, AppStrings.creditB50, 'Empurple'), + _row(theme, AppStrings.creditQRDecode, '原生 MethodChannel'), + _row(theme, AppStrings.creditIconAssets, 'assets2.lxns.net'), + ], ), ), + const SizedBox(height: 12), + const AppNotice( + AppStrings.aboutRiskNotice, + icon: Icons.warning_amber_rounded, + ), ], ), ), ); } - Widget _creditRow(ThemeData theme, String label, String value) { + Widget _row(ThemeData theme, String label, String value) { return Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( - width: 120, + width: 104, child: Text( label, style: theme.textTheme.bodySmall?.copyWith( @@ -83,9 +103,7 @@ class AboutPage extends StatelessWidget { ), ), ), - Expanded( - child: Text(value, style: theme.textTheme.bodyMedium), - ), + Expanded(child: Text(value, style: theme.textTheme.bodyMedium)), ], ), ); diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 48f40da..26369cd 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -10,23 +10,33 @@ import '../models/session_model.dart'; import '../models/user_data.dart'; import '../models/user_preview.dart'; import '../services/title_api_service.dart'; +import '../widgets/app_notice.dart'; +import '../widgets/connection_share_card.dart'; import 'about_page.dart'; import 'best50_page.dart'; import 'high_risk_feature_page.dart'; import 'settings_page.dart'; import 'ticket_page.dart'; - class HomePage extends StatefulWidget { final int userId; final String token; final bool forcePreviewApi; + /// 会话由登录页粘贴「连接信息」恢复而来:登录态(或 Cookie)已经是现成的, + /// 主页不要再发一次 UserLoginApi 去顶掉它。 + final bool sessionRestored; + + /// 恢复会话时验证 Cookie 顺手拉到的用户数据,直接展示、省一次请求。 + final UserDataBean? initialUserData; + const HomePage({ super.key, required this.userId, required this.token, this.forcePreviewApi = false, + this.sessionRestored = false, + this.initialUserData, }); @override @@ -73,7 +83,19 @@ class _HomePageState extends State { _preview = null; _userData = null; }); - _session.setGameLogin(null); + + final seed = widget.initialUserData; + if (seed != null) { + // 恢复来的 JSESSIONID 已在 SessionModel._probeCookieSession 里验证过并 + // 带回全量数据,这里直接展示,不再发 GetUserPreviewApi / UserLoginApi。 + setState(() { + _userData = seed; + _loading = false; + }); + return; + } + + if (!widget.sessionRestored) _session.setGameLogin(null); final service = TitleApiService.fromHolder(cookies: _session.cookies)!; @@ -88,19 +110,21 @@ class _HomePageState extends State { // isLogin == true => 已有人登录, 不再走完整登录, 直接展示 preview 概要 // forcePreviewApi => 跳过 UserLoginApi, 仅展示 preview + Login 按钮 - if (preview.isLogin || widget.forcePreviewApi) { + // 但恢复来的登录态就是自己这次会话,isLogin 为 true 不代表「别人在玩」。 + final ownsLogin = _session.gameLogin != null; + if (!ownsLogin && (preview.isLogin || widget.forcePreviewApi)) { setState(() => _loading = false); return; } - await _session.loginGame( - userId: widget.userId, - token: widget.token, - ); - if (!mounted) return; + if (!ownsLogin) { + await _session.loginGame(userId: widget.userId, token: widget.token); + if (!mounted) return; + } - final userDataService = - TitleApiService.fromHolder(cookies: _session.cookies)!; + final userDataService = TitleApiService.fromHolder( + cookies: _session.cookies, + )!; final userData = await userDataService.getUserDataTyped(widget.userId); if (!mounted) return; _session.updateCookies(userDataService.cookies); @@ -131,10 +155,7 @@ class _HomePageState extends State { }); try { - await _session.loginGame( - userId: widget.userId, - token: widget.token, - ); + await _session.loginGame(userId: widget.userId, token: widget.token); if (!mounted) return; final service = TitleApiService.fromHolder(cookies: _session.cookies)!; @@ -170,7 +191,7 @@ class _HomePageState extends State { Future _performLogoutAndExit() async { setState(() => _loggingOut = true); if (_session.gameLogin != null) { - await Future.delayed(const Duration(seconds: 5)); + await Future.delayed(const Duration(seconds: 2)); await _logoutSession(); } _session.reset(); @@ -185,6 +206,9 @@ class _HomePageState extends State { /// 主页上的原生返回手势 = 结束应用。 void _exitApp() { // iOS 不允许程序化退出,且根路由本来也没有返回手势。 + if (_session.gameLogin != null) { + _logoutSession(); + } if (Platform.isIOS) return; SystemNavigator.pop(); } @@ -227,15 +251,19 @@ class _HomePageState extends State { userId: widget.userId, cookies: _session.cookies, loginDateTime: _session.gameLogin?.loginDateTime, - playerRating: _userData?.playerRating ?? _preview?.playerRating ?? 0, - onLogoutRequested: _session.gameLogin != null ? _logoutSession : null, + playerRating: + _userData?.playerRating ?? _preview?.playerRating ?? 0, + onLogoutRequested: _session.gameLogin != null + ? _logoutSession + : null, ), Best50Page( userId: widget.userId, cookies: _session.cookies, userName: _userData?.userName ?? _preview?.userName ?? '', iconId: _userData?.iconId ?? _preview?.iconId ?? 0, - playerRating: _userData?.playerRating ?? _preview?.playerRating ?? 0, + playerRating: + _userData?.playerRating ?? _preview?.playerRating ?? 0, ), HighRiskFeaturePage( userId: widget.userId, @@ -243,8 +271,9 @@ class _HomePageState extends State { loginDateTime: _session.gameLogin?.loginDateTime, loginId: _session.gameLogin?.loginId, lastLoginDate: _session.gameLogin?.lastLoginDate, - onExitToTitle: - _session.gameLogin != null ? _performLogoutAndExit : null, + onExitToTitle: _session.gameLogin != null + ? _performLogoutAndExit + : null, ), SettingsPage(showAppBar: false), const AboutPage(), @@ -254,12 +283,36 @@ class _HomePageState extends State { selectedIndex: _currentTab, onDestinationSelected: (i) => setState(() => _currentTab = i), destinations: const [ - NavigationDestination(icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home), label: AppStrings.tabHome), - NavigationDestination(icon: Icon(Icons.confirmation_number_outlined), selectedIcon: Icon(Icons.confirmation_number), label: AppStrings.tabTickets), - NavigationDestination(icon: Icon(Icons.bar_chart_outlined), selectedIcon: Icon(Icons.bar_chart), label: AppStrings.tabBest50), - NavigationDestination(icon: Icon(Icons.warning_outlined), selectedIcon: Icon(Icons.inventory_2), label: AppStrings.tabRisk), - NavigationDestination(icon: Icon(Icons.settings_outlined), selectedIcon: Icon(Icons.settings), label: AppStrings.tabSettings), - NavigationDestination(icon: Icon(Icons.info_outlined), selectedIcon: Icon(Icons.info), label: AppStrings.tabAbout), + NavigationDestination( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), + label: AppStrings.tabHome, + ), + NavigationDestination( + icon: Icon(Icons.confirmation_number_outlined), + selectedIcon: Icon(Icons.confirmation_number), + label: AppStrings.tabTickets, + ), + NavigationDestination( + icon: Icon(Icons.bar_chart_outlined), + selectedIcon: Icon(Icons.bar_chart), + label: AppStrings.tabBest50, + ), + NavigationDestination( + icon: Icon(Icons.warning_outlined), + selectedIcon: Icon(Icons.inventory_2), + label: AppStrings.tabRisk, + ), + NavigationDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: AppStrings.tabSettings, + ), + NavigationDestination( + icon: Icon(Icons.info_outlined), + selectedIcon: Icon(Icons.info), + label: AppStrings.tabAbout, + ), ], ), ), @@ -276,7 +329,11 @@ class _HomePageState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.settings_ethernet, size: 64, color: theme.colorScheme.primary), + Icon( + Icons.settings_ethernet, + size: 64, + color: theme.colorScheme.primary, + ), const SizedBox(height: 16), Text( AppStrings.titleServerNotConfigured, @@ -313,7 +370,11 @@ class _HomePageState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.error_outline, size: 48, color: theme.colorScheme.error), + Icon( + Icons.error_outline, + size: 48, + color: theme.colorScheme.error, + ), const SizedBox(height: 12), Text(AppStrings.loadFailed, style: theme.textTheme.titleMedium), const SizedBox(height: 8), @@ -337,13 +398,18 @@ class _HomePageState extends State { ); } - final preview = _preview; final userData = _userData; + if (userData != null) { + return _buildFullData(theme, _preview, userData); + } + + final preview = _preview; if (preview == null) { return const SizedBox.shrink(); } - if ((preview.isLogin || (widget.forcePreviewApi && _session.gameLogin == null)) && userData == null) { + if (preview.isLogin || + (widget.forcePreviewApi && _session.gameLogin == null)) { return _buildPreviewOnly( theme, preview, @@ -352,10 +418,6 @@ class _HomePageState extends State { ); } - if (userData != null) { - return _buildFullData(theme, preview, userData); - } - return const SizedBox.shrink(); } @@ -371,16 +433,21 @@ class _HomePageState extends State { context, child: Column( children: [ - if (preview.errorId != 0) _ErrorIdBanner(theme: theme, errorId: preview.errorId), + if (preview.errorId != 0) + _ErrorIdBanner(theme: theme, errorId: preview.errorId), Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( - color: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.4), + color: theme.colorScheme.tertiaryContainer.withValues( + alpha: 0.4, + ), borderRadius: BorderRadius.circular(10), ), child: Text( - showLoginButton ? 'Preview API 模式,点击下方按钮登录。' : AppStrings.inheritedAccountNotice, + showLoginButton + ? 'Preview API 模式,点击下方按钮登录。' + : AppStrings.inheritedAccountNotice, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onTertiaryContainer, ), @@ -399,7 +466,9 @@ class _HomePageState extends State { child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.login, size: 20), - label: Text(_loading ? AppStrings.loggingIn : AppStrings.login), + label: Text( + _loading ? AppStrings.loggingIn : AppStrings.login, + ), style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( @@ -419,6 +488,8 @@ class _HomePageState extends State { _PreviewStatusCard(theme: theme, preview: preview), ], ), + const SizedBox(height: 12), + ConnectionShareCard(userId: widget.userId, token: widget.token), if (TitleApiService.lastRawResponse != null) ...[ const SizedBox(height: 12), _DebugRawJsonCard( @@ -434,7 +505,7 @@ class _HomePageState extends State { Widget _buildFullData( ThemeData theme, - UserPreviewDataBean preview, + UserPreviewDataBean? preview, UserDataBean data, ) { return SingleChildScrollView( @@ -443,6 +514,11 @@ class _HomePageState extends State { context, child: Column( children: [ + if (widget.sessionRestored && _session.gameLogin == null) + AppNotice( + AppStrings.sessionRestoredNotice, + icon: Icons.science_outlined, + ), _ProfileFromUserDataCard(theme: theme, data: data), const SizedBox(height: 12), ...responsiveGrid( @@ -452,10 +528,16 @@ class _HomePageState extends State { _FirstPlayCard(theme: theme, data: data), _GameInfoFromUserDataCard(theme: theme, data: data), _PlayStatsCard(theme: theme, data: data), - _StatusFromUserDataCard(theme: theme, preview: preview, data: data), + _StatusFromUserDataCard( + theme: theme, + preview: preview, + data: data, + ), _UserDataDetailCard(theme: theme, data: data), ], ), + const SizedBox(height: 12), + ConnectionShareCard(userId: widget.userId, token: widget.token), if (TitleApiService.lastRawResponse != null) ...[ const SizedBox(height: 12), _DebugRawJsonCard( @@ -486,7 +568,9 @@ class _ProfileFromPreviewCard extends StatelessWidget { elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - side: BorderSide(color: theme.colorScheme.outline.withValues(alpha: 0.3)), + side: BorderSide( + color: theme.colorScheme.outline.withValues(alpha: 0.3), + ), ), child: Padding( padding: const EdgeInsets.all(20), @@ -499,8 +583,12 @@ class _ProfileFromPreviewCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - preview.userName.isEmpty ? AppStrings.unknownUser : preview.userName, - style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + preview.userName.isEmpty + ? AppStrings.unknownUser + : preview.userName, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), ), const SizedBox(height: 4), Text( @@ -554,7 +642,9 @@ class _PreviewStatusCard extends StatelessWidget { elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - side: BorderSide(color: theme.colorScheme.outline.withValues(alpha: 0.3)), + side: BorderSide( + color: theme.colorScheme.outline.withValues(alpha: 0.3), + ), ), child: Padding( padding: const EdgeInsets.all(20), @@ -601,7 +691,9 @@ class _ProfileFromUserDataCard extends StatelessWidget { elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - side: BorderSide(color: theme.colorScheme.outline.withValues(alpha: 0.3)), + side: BorderSide( + color: theme.colorScheme.outline.withValues(alpha: 0.3), + ), ), child: Padding( padding: const EdgeInsets.all(20), @@ -614,8 +706,12 @@ class _ProfileFromUserDataCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - data.userName.isEmpty ? AppStrings.unknownUser : data.userName, - style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + data.userName.isEmpty + ? AppStrings.unknownUser + : data.userName, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), ), const SizedBox(height: 4), Text( @@ -701,7 +797,10 @@ class _GameInfoFromUserDataCard extends StatelessWidget { (AppStrings.lastLogin, data.lastLoginDate), (AppStrings.romVersion, data.lastRomVersion), (AppStrings.dataVersion, data.lastDataVersion), - (AppStrings.lastRegion, '${data.lastRegionName}${data.lastRegionId > 0 ? ' (${data.lastRegionId})' : ''}'), + ( + AppStrings.lastRegion, + '${data.lastRegionName}${data.lastRegionId > 0 ? ' (${data.lastRegionId})' : ''}', + ), ], ); } @@ -732,7 +831,9 @@ class _PlayStatsCard extends StatelessWidget { class _StatusFromUserDataCard extends StatelessWidget { final ThemeData theme; - final UserPreviewDataBean preview; + + /// 用连接信息(仅 JSESSIONID)恢复的会话没有走 GetUserPreviewApi,可为空。 + final UserPreviewDataBean? preview; final UserDataBean data; const _StatusFromUserDataCard({ @@ -743,11 +844,15 @@ class _StatusFromUserDataCard extends StatelessWidget { @override Widget build(BuildContext context) { + final p = preview; + return Card( elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - side: BorderSide(color: theme.colorScheme.outline.withValues(alpha: 0.3)), + side: BorderSide( + color: theme.colorScheme.outline.withValues(alpha: 0.3), + ), ), child: Padding( padding: const EdgeInsets.all(20), @@ -763,7 +868,7 @@ class _StatusFromUserDataCard extends StatelessWidget { ), const SizedBox(height: 14), _statusChip(theme, AppStrings.netMember, data.isNetMember), - _statusChip(theme, AppStrings.inherit, preview.isInherit), + if (p != null) _statusChip(theme, AppStrings.inherit, p.isInherit), _statusChip( theme, AppStrings.banState, @@ -808,7 +913,9 @@ class _UserDataDetailCardState extends State<_UserDataDetailCard> { elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - side: BorderSide(color: theme.colorScheme.outline.withValues(alpha: 0.3)), + side: BorderSide( + color: theme.colorScheme.outline.withValues(alpha: 0.3), + ), ), child: InkWell( borderRadius: BorderRadius.circular(16), @@ -841,7 +948,11 @@ class _UserDataDetailCardState extends State<_UserDataDetailCard> { _detailRow(theme, AppStrings.point, '${data.point}'), _detailRow(theme, AppStrings.totalPoint, '${data.totalPoint}'), _detailRow(theme, AppStrings.iconId, '${data.iconId}'), - _detailRow(theme, AppStrings.nameplateId, '${data.nameplateId}'), + _detailRow( + theme, + AppStrings.nameplateId, + '${data.nameplateId}', + ), _detailRow(theme, AppStrings.plateId, '${data.plateId}'), _detailRow(theme, AppStrings.frameId, '${data.frameId}'), _detailRow(theme, AppStrings.titleId, '${data.titleId}'), @@ -872,9 +983,7 @@ class _UserDataDetailCardState extends State<_UserDataDetailCard> { ), ), ), - Expanded( - child: Text(value, style: theme.textTheme.bodyMedium), - ), + Expanded(child: Text(value, style: theme.textTheme.bodyMedium)), ], ), ); @@ -902,7 +1011,9 @@ class _SectionCard extends StatelessWidget { elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - side: BorderSide(color: theme.colorScheme.outline.withValues(alpha: 0.3)), + side: BorderSide( + color: theme.colorScheme.outline.withValues(alpha: 0.3), + ), ), child: Padding( padding: const EdgeInsets.all(20), @@ -1021,10 +1132,7 @@ class _DebugRawJsonCard extends StatefulWidget { final ThemeData theme; final String rawJson; - const _DebugRawJsonCard({ - required this.theme, - required this.rawJson, - }); + const _DebugRawJsonCard({required this.theme, required this.rawJson}); @override State<_DebugRawJsonCard> createState() => _DebugRawJsonCardState(); @@ -1076,8 +1184,9 @@ class _DebugRawJsonCardState extends State<_DebugRawJsonCard> { width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest - .withValues(alpha: 0.5), + color: theme.colorScheme.surfaceContainerHighest.withValues( + alpha: 0.5, + ), borderRadius: BorderRadius.circular(8), ), child: SelectableText( @@ -1115,8 +1224,11 @@ class _ErrorIdBanner extends StatelessWidget { ), child: Row( children: [ - Icon(Icons.warning_amber_rounded, - size: 20, color: theme.colorScheme.onErrorContainer), + Icon( + Icons.warning_amber_rounded, + size: 20, + color: theme.colorScheme.onErrorContainer, + ), const SizedBox(width: 10), Expanded( child: Text( diff --git a/lib/pages/login_page.dart b/lib/pages/login_page.dart index e8694e6..5c910e3 100644 --- a/lib/pages/login_page.dart +++ b/lib/pages/login_page.dart @@ -3,12 +3,16 @@ import 'package:flutter/material.dart'; import '../config/responsive.dart'; import '../config/strings.dart'; import '../models/session_model.dart'; +import '../models/session_share.dart'; import '../services/file_picker_service.dart'; import '../services/qr_service.dart'; +import '../widgets/app_notice.dart'; import 'home_page.dart'; import 'settings_page.dart'; /// 登录页:粘贴 / 识别 QR 令牌,走 Aime 登录后进入主页。 +/// +/// 也接受主页导出的「连接信息」(实验性):那是一串 Base64,直接续上原有会话。 class LoginPage extends StatefulWidget { const LoginPage({super.key}); @@ -19,6 +23,7 @@ class LoginPage extends StatefulWidget { class _LoginPageState extends State { final _qrController = TextEditingController(); String _qrCode = ''; + SessionShare? _share; bool _loading = false; bool _forcePreviewApi = false; @@ -29,8 +34,10 @@ class _LoginPageState extends State { } void _onQRContentChanged(String value) { + final text = value.trim(); setState(() { - _qrCode = SessionModel.extractQRCode(value.trim()); + _qrCode = SessionModel.extractQRCode(text); + _share = SessionShare.tryDecode(text); }); } @@ -45,6 +52,7 @@ class _LoginPageState extends State { _qrController.text = result; setState(() { _qrCode = SessionModel.extractQRCode(result); + _share = SessionShare.tryDecode(result); }); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -70,44 +78,85 @@ class _LoginPageState extends State { Future _onLogin() async { if (_qrCode.isEmpty) return; - setState(() => _loading = true); + if (_qrCode.startsWith("SGWCMAID")) { + setState(() => _loading = true); - try { - final result = await SessionModel.instance.loginWithQr(_qrCode); + try { + final result = await SessionModel.instance.loginWithQr(_qrCode); - if (!mounted) return; + if (!mounted) return; - if (result.success) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => HomePage( - userId: result.userId, - token: result.token, - forcePreviewApi: _forcePreviewApi, + if (result.success) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => HomePage( + userId: result.userId, + token: result.token, + forcePreviewApi: _forcePreviewApi, + ), ), - ), - ); - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('${AppStrings.loginFailed} (errorID: ${result.errorId})')), - ); + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${AppStrings.loginFailed} (errorID: ${result.errorId})', + ), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${AppStrings.requestFailed}: $e')), + ); + } + } finally { + if (mounted) { + setState(() => _loading = false); + } } + } + } + + /// 实验性:用主页导出的连接信息恢复会话。带可用 JSESSIONID 就直接续上那次登录, + /// 否则退回用令牌重新登录。 + Future _onRestoreSession() async { + final share = _share; + if (share == null) return; + + setState(() => _loading = true); + try { + final result = await SessionModel.instance.restoreFromShare(share); + if (!mounted) return; + context.showSnack( + result.reusedCookie + ? AppStrings.sessionRestoreCookie + : AppStrings.sessionRestoreToken, + ); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => HomePage( + userId: share.userId, + token: share.token, + sessionRestored: true, + initialUserData: result.userData, + ), + ), + ); } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('${AppStrings.requestFailed}: $e')), - ); + context.showSnack('${AppStrings.sessionRestoreFailed}: $e'); } } finally { - if (mounted) { - setState(() => _loading = false); - } + if (mounted) setState(() => _loading = false); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); + final isRestore = _share != null; return Scaffold( appBar: AppBar( @@ -117,9 +166,9 @@ class _LoginPageState extends State { icon: const Icon(Icons.settings), tooltip: AppStrings.settingsTooltip, onPressed: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const SettingsPage()), - ); + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const SettingsPage())); }, ), ], @@ -145,14 +194,7 @@ class _LoginPageState extends State { color: theme.colorScheme.primary, ), ), - const SizedBox(height: 8), - Text( - AppStrings.loginSubtitle, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 40), + const SizedBox(height: 48), Card( elevation: 0, @@ -194,6 +236,7 @@ class _LoginPageState extends State { letterSpacing: 0.5, ), decoration: InputDecoration( + hintText: AppStrings.qrHint, border: OutlineInputBorder( borderRadius: BorderRadius.circular(10), ), @@ -205,40 +248,14 @@ class _LoginPageState extends State { ), ), - if (_qrCode.isNotEmpty) ...[ - const SizedBox(height: 12), - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest - .withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(10), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - AppStrings.qrParsedResult, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 6), - Text( - _qrCode, - style: theme.textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - color: theme.colorScheme.onSurface, - ), - ), - ], - ), - ), - ], - const SizedBox(height: 28), + if (isRestore) + const AppNotice( + AppStrings.sessionShareDetected, + icon: Icons.science_outlined, + ), + Row( children: [ Expanded( @@ -257,8 +274,11 @@ class _LoginPageState extends State { const SizedBox(width: 16), Expanded( child: FilledButton.icon( - onPressed: - (_qrCode.isNotEmpty && !_loading) ? _onLogin : null, + onPressed: isRestore + ? (_loading ? null : _onRestoreSession) + : (_qrCode.isNotEmpty && !_loading) + ? _onLogin + : null, icon: _loading ? const SizedBox( width: 20, @@ -268,8 +288,19 @@ class _LoginPageState extends State { color: Colors.white, ), ) - : const Icon(Icons.arrow_forward, size: 20), - label: Text(_loading ? AppStrings.loggingIn : AppStrings.login), + : Icon( + isRestore ? Icons.link : Icons.arrow_forward, + size: 20, + ), + label: Text( + isRestore + ? (_loading + ? AppStrings.restoringSession + : AppStrings.restoreSession) + : (_loading + ? AppStrings.loggingIn + : AppStrings.login), + ), style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( @@ -281,14 +312,16 @@ class _LoginPageState extends State { ], ), const SizedBox(height: 16), - CheckboxListTile( - value: _forcePreviewApi, - onChanged: (v) => setState(() => _forcePreviewApi = v ?? false), - title: const Text(AppStrings.forcePreviewApi), - controlAffinity: ListTileControlAffinity.leading, - contentPadding: EdgeInsets.zero, - dense: true, - ), + if (!isRestore) + CheckboxListTile( + value: _forcePreviewApi, + onChanged: (v) => + setState(() => _forcePreviewApi = v ?? false), + title: const Text(AppStrings.forcePreviewApi), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + dense: true, + ), ], ), ), diff --git a/lib/widgets/connection_share_card.dart b/lib/widgets/connection_share_card.dart new file mode 100644 index 0000000..3d21d54 --- /dev/null +++ b/lib/widgets/connection_share_card.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show Clipboard, ClipboardData; + +import '../config/strings.dart'; +import '../models/session_model.dart'; +import '../models/session_share.dart'; +import 'app_card.dart'; +import 'app_notice.dart'; + +/// 实验性:把当前会话打包成 Base64「连接信息」,在别的设备粘贴即可恢复。 +class ConnectionShareCard extends StatefulWidget { + final int userId; + final String token; + + const ConnectionShareCard({ + super.key, + required this.userId, + required this.token, + }); + + @override + State createState() => _ConnectionShareCardState(); +} + +class _ConnectionShareCardState extends State { + String? _encoded; + + SessionShare get _share => SessionModel.instance.exportConnectionInfo( + userId: widget.userId, + token: widget.token, + ); + + Future _copy() async { + final share = _share; + if (!share.hasCookie && !share.hasToken) { + context.showSnack(AppStrings.connectionShareEmpty); + return; + } + + final text = share.encode(); + await Clipboard.setData(ClipboardData(text: text)); + if (!mounted) return; + setState(() => _encoded = text); + context.showSnack(AppStrings.connectionShareCopied); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: SectionTitle( + icon: Icons.link, + title: AppStrings.connectionShareTitle, + ), + ), + const _ExperimentalBadge(), + ], + ), + const SizedBox(height: 10), + Text( + AppStrings.connectionShareDesc, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 14), + SizedBox( + width: double.infinity, + child: FilledButton.tonalIcon( + onPressed: _copy, + icon: const Icon(Icons.copy_all, size: 20), + label: const Text(AppStrings.connectionShareCopy), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 13), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + if (_encoded != null) ...[ + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues( + alpha: 0.5, + ), + borderRadius: BorderRadius.circular(8), + ), + child: SingleChildScrollView( + child: SelectableText( + _encoded!, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + fontSize: 11, + ), + ), + ), + ), + const SizedBox(height: 10), + Text( + AppStrings.connectionShareSecurity, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], + ], + ), + ); + } +} + +class _ExperimentalBadge extends StatelessWidget { + const _ExperimentalBadge(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tint = theme.colorScheme.tertiary; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: tint.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: tint.withValues(alpha: 0.5)), + ), + child: Text( + AppStrings.experimentalBadge, + style: theme.textTheme.labelSmall?.copyWith( + color: tint, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} diff --git a/test/session_share_test.dart b/test/session_share_test.dart new file mode 100644 index 0000000..7beffc7 --- /dev/null +++ b/test/session_share_test.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ichino/config/strings.dart'; +import 'package:ichino/models/session_model.dart'; +import 'package:ichino/models/session_share.dart'; +import 'package:ichino/widgets/connection_share_card.dart'; + +void main() { + group('SessionShare', () { + test('编码后能解回同样的会话', () { + const share = SessionShare( + userId: 1234567, + token: 'SGWCMAIDABCDEF', + cookie: 'JSESSIONID=abc123DEF', + ); + + final decoded = SessionShare.tryDecode(share.encode()); + + expect(decoded, isNotNull); + expect(decoded!.userId, 1234567); + expect(decoded.token, 'SGWCMAIDABCDEF'); + expect(decoded.jsessionid, 'JSESSIONID=abc123DEF'); + }); + + test('只保留 JSESSIONID,忽略其它 Cookie', () { + const share = SessionShare( + userId: 1, + token: 't', + cookie: 'OTHER=x; JSESSIONID=keep-me; Path=/', + ); + + final decoded = SessionShare.tryDecode(share.encode())!; + + expect(decoded.jsessionid, 'JSESSIONID=keep-me'); + }); + + test('换行与空白不影响解析', () { + const share = SessionShare( + userId: 42, + token: 'tok', + cookie: 'JSESSIONID=z', + ); + final wrapped = + ' ${share.encode().replaceAllMapped(RegExp(r'.{8}'), (m) => '${m[0]}\n')} '; + + expect(SessionShare.tryDecode(wrapped)?.userId, 42); + }); + + test('QR 令牌与乱码不是连接信息', () { + final qrToken = 'SGWCMAID${'0123456789ABCDEF' * 3}01234567'; + expect(qrToken.length, 64); + expect(SessionShare.tryDecode(qrToken), isNull); + expect(SessionShare.tryDecode('not base64 at all !!'), isNull); + expect(SessionShare.tryDecode(''), isNull); + }); + + test('cookie 与 token 都空、或 userId 非法时拒绝', () { + expect( + SessionShare.tryDecode( + const SessionShare( + userId: 0, + token: '', + cookie: 'JSESSIONID=x', + ).encode(), + ), + isNull, + ); + expect( + SessionShare.tryDecode( + const SessionShare(userId: 7, token: '', cookie: null).encode(), + ), + isNull, + ); + }); + }); + + group('ConnectionShareCard', () { + Widget wrap(Widget child) => MaterialApp( + home: Scaffold(body: SingleChildScrollView(child: child)), + ); + + testWidgets('复制到剪贴板并展开 Base64 内容与外传警示', (tester) async { + final copied = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'Clipboard.setData') { + copied.add(((call.arguments as Map)['text'] as String?) ?? ''); + } + return null; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + }); + + await tester.pumpWidget( + wrap(const ConnectionShareCard(userId: 7, token: 'tok')), + ); + + expect(find.text(AppStrings.experimentalBadge), findsOneWidget); + expect(find.text(AppStrings.connectionShareSecurity), findsNothing); + + await tester.tap(find.text(AppStrings.connectionShareCopy)); + await tester.pump(); + + final encoded = SessionModel.instance + .exportConnectionInfo(userId: 7, token: 'tok') + .encode(); + expect(copied, [encoded]); + expect(find.text(AppStrings.connectionShareSecurity), findsOneWidget); + expect(find.text(encoded), findsOneWidget); + }); + }); +}