diff --git a/android/app/src/main/res/drawable-night-xhdpi/jinzhi_onetap_back_daynight.png b/android/app/src/main/res/drawable-night-xhdpi/jinzhi_onetap_back_daynight.png new file mode 100644 index 0000000..305ac19 Binary files /dev/null and b/android/app/src/main/res/drawable-night-xhdpi/jinzhi_onetap_back_daynight.png differ diff --git a/android/app/src/main/res/drawable-night/jinzhi_onetap_back_daynight.xml b/android/app/src/main/res/drawable-night/jinzhi_onetap_back_daynight.xml new file mode 100644 index 0000000..181f897 --- /dev/null +++ b/android/app/src/main/res/drawable-night/jinzhi_onetap_back_daynight.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable-xhdpi/jinzhi_onetap_back.png b/android/app/src/main/res/drawable-xhdpi/jinzhi_onetap_back.png new file mode 100644 index 0000000..d84cc9e Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/jinzhi_onetap_back.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/jinzhi_onetap_back_daynight.png b/android/app/src/main/res/drawable-xhdpi/jinzhi_onetap_back_daynight.png new file mode 100644 index 0000000..465d06f Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/jinzhi_onetap_back_daynight.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/jinzhi_onetap_button.png b/android/app/src/main/res/drawable-xhdpi/jinzhi_onetap_button.png new file mode 100644 index 0000000..a84ca6f Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/jinzhi_onetap_button.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/jinzhi_onetap_button_pressed.png b/android/app/src/main/res/drawable-xhdpi/jinzhi_onetap_button_pressed.png new file mode 100644 index 0000000..cc64c57 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/jinzhi_onetap_button_pressed.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/jinzhi_onetap_checked.png b/android/app/src/main/res/drawable-xhdpi/jinzhi_onetap_checked.png new file mode 100644 index 0000000..deb090a Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/jinzhi_onetap_checked.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/jinzhi_onetap_unchecked.png b/android/app/src/main/res/drawable-xhdpi/jinzhi_onetap_unchecked.png new file mode 100644 index 0000000..1bfff56 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/jinzhi_onetap_unchecked.png differ diff --git a/android/app/src/main/res/drawable/jinzhi_onetap_back_daynight.xml b/android/app/src/main/res/drawable/jinzhi_onetap_back_daynight.xml new file mode 100644 index 0000000..fab349f --- /dev/null +++ b/android/app/src/main/res/drawable/jinzhi_onetap_back_daynight.xml @@ -0,0 +1,9 @@ + + + diff --git a/ios/Runner/Assets.xcassets/jinzhi_onetap_back.imageset/Contents.json b/ios/Runner/Assets.xcassets/jinzhi_onetap_back.imageset/Contents.json new file mode 100644 index 0000000..ecd626a --- /dev/null +++ b/ios/Runner/Assets.xcassets/jinzhi_onetap_back.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"jinzhi_onetap_back.png","idiom":"universal","scale":"1x"},{"idiom":"universal","scale":"2x"},{"idiom":"universal","scale":"3x"}],"info":{"author":"xcode","version":1}} diff --git a/ios/Runner/Assets.xcassets/jinzhi_onetap_back.imageset/jinzhi_onetap_back.png b/ios/Runner/Assets.xcassets/jinzhi_onetap_back.imageset/jinzhi_onetap_back.png new file mode 100644 index 0000000..d84cc9e Binary files /dev/null and b/ios/Runner/Assets.xcassets/jinzhi_onetap_back.imageset/jinzhi_onetap_back.png differ diff --git a/ios/Runner/Assets.xcassets/jinzhi_onetap_back_day.imageset/Contents.json b/ios/Runner/Assets.xcassets/jinzhi_onetap_back_day.imageset/Contents.json new file mode 100644 index 0000000..adbb168 --- /dev/null +++ b/ios/Runner/Assets.xcassets/jinzhi_onetap_back_day.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "filename" : "jinzhi_onetap_back_day.png", "idiom" : "universal", "scale" : "1x" }, + { "filename" : "jinzhi_onetap_back_day@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "jinzhi_onetap_back_day@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/ios/Runner/Assets.xcassets/jinzhi_onetap_back_day.imageset/jinzhi_onetap_back_day.png b/ios/Runner/Assets.xcassets/jinzhi_onetap_back_day.imageset/jinzhi_onetap_back_day.png new file mode 100644 index 0000000..27ab3be Binary files /dev/null and b/ios/Runner/Assets.xcassets/jinzhi_onetap_back_day.imageset/jinzhi_onetap_back_day.png differ diff --git a/ios/Runner/Assets.xcassets/jinzhi_onetap_back_day.imageset/jinzhi_onetap_back_day@2x.png b/ios/Runner/Assets.xcassets/jinzhi_onetap_back_day.imageset/jinzhi_onetap_back_day@2x.png new file mode 100644 index 0000000..f0ddd07 Binary files /dev/null and b/ios/Runner/Assets.xcassets/jinzhi_onetap_back_day.imageset/jinzhi_onetap_back_day@2x.png differ diff --git a/ios/Runner/Assets.xcassets/jinzhi_onetap_back_day.imageset/jinzhi_onetap_back_day@3x.png b/ios/Runner/Assets.xcassets/jinzhi_onetap_back_day.imageset/jinzhi_onetap_back_day@3x.png new file mode 100644 index 0000000..3d3ebd4 Binary files /dev/null and b/ios/Runner/Assets.xcassets/jinzhi_onetap_back_day.imageset/jinzhi_onetap_back_day@3x.png differ diff --git a/ios/Runner/Assets.xcassets/jinzhi_onetap_back_night.imageset/Contents.json b/ios/Runner/Assets.xcassets/jinzhi_onetap_back_night.imageset/Contents.json new file mode 100644 index 0000000..7adef82 --- /dev/null +++ b/ios/Runner/Assets.xcassets/jinzhi_onetap_back_night.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "filename" : "jinzhi_onetap_back_night.png", "idiom" : "universal", "scale" : "1x" }, + { "filename" : "jinzhi_onetap_back_night@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "jinzhi_onetap_back_night@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/ios/Runner/Assets.xcassets/jinzhi_onetap_back_night.imageset/jinzhi_onetap_back_night.png b/ios/Runner/Assets.xcassets/jinzhi_onetap_back_night.imageset/jinzhi_onetap_back_night.png new file mode 100644 index 0000000..7de7bf7 Binary files /dev/null and b/ios/Runner/Assets.xcassets/jinzhi_onetap_back_night.imageset/jinzhi_onetap_back_night.png differ diff --git a/ios/Runner/Assets.xcassets/jinzhi_onetap_back_night.imageset/jinzhi_onetap_back_night@2x.png b/ios/Runner/Assets.xcassets/jinzhi_onetap_back_night.imageset/jinzhi_onetap_back_night@2x.png new file mode 100644 index 0000000..b9a8951 Binary files /dev/null and b/ios/Runner/Assets.xcassets/jinzhi_onetap_back_night.imageset/jinzhi_onetap_back_night@2x.png differ diff --git a/ios/Runner/Assets.xcassets/jinzhi_onetap_back_night.imageset/jinzhi_onetap_back_night@3x.png b/ios/Runner/Assets.xcassets/jinzhi_onetap_back_night.imageset/jinzhi_onetap_back_night@3x.png new file mode 100644 index 0000000..08783ce Binary files /dev/null and b/ios/Runner/Assets.xcassets/jinzhi_onetap_back_night.imageset/jinzhi_onetap_back_night@3x.png differ diff --git a/ios/Runner/Assets.xcassets/jinzhi_onetap_button.imageset/Contents.json b/ios/Runner/Assets.xcassets/jinzhi_onetap_button.imageset/Contents.json new file mode 100644 index 0000000..b040bcf --- /dev/null +++ b/ios/Runner/Assets.xcassets/jinzhi_onetap_button.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "filename" : "jinzhi_onetap_button.png", "idiom" : "universal", "scale" : "1x" }, + { "filename" : "jinzhi_onetap_button@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "jinzhi_onetap_button@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/ios/Runner/Assets.xcassets/jinzhi_onetap_button.imageset/jinzhi_onetap_button.png b/ios/Runner/Assets.xcassets/jinzhi_onetap_button.imageset/jinzhi_onetap_button.png new file mode 100644 index 0000000..52e624b Binary files /dev/null and b/ios/Runner/Assets.xcassets/jinzhi_onetap_button.imageset/jinzhi_onetap_button.png differ diff --git a/ios/Runner/Assets.xcassets/jinzhi_onetap_button.imageset/jinzhi_onetap_button@2x.png b/ios/Runner/Assets.xcassets/jinzhi_onetap_button.imageset/jinzhi_onetap_button@2x.png new file mode 100644 index 0000000..cd20779 Binary files /dev/null and b/ios/Runner/Assets.xcassets/jinzhi_onetap_button.imageset/jinzhi_onetap_button@2x.png differ diff --git a/ios/Runner/Assets.xcassets/jinzhi_onetap_button.imageset/jinzhi_onetap_button@3x.png b/ios/Runner/Assets.xcassets/jinzhi_onetap_button.imageset/jinzhi_onetap_button@3x.png new file mode 100644 index 0000000..21a2c92 Binary files /dev/null and b/ios/Runner/Assets.xcassets/jinzhi_onetap_button.imageset/jinzhi_onetap_button@3x.png differ diff --git a/ios/Runner/Assets.xcassets/jinzhi_onetap_button_pressed.imageset/Contents.json b/ios/Runner/Assets.xcassets/jinzhi_onetap_button_pressed.imageset/Contents.json new file mode 100644 index 0000000..690d0ef --- /dev/null +++ b/ios/Runner/Assets.xcassets/jinzhi_onetap_button_pressed.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "filename" : "jinzhi_onetap_button_pressed.png", "idiom" : "universal", "scale" : "1x" }, + { "filename" : "jinzhi_onetap_button_pressed@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "jinzhi_onetap_button_pressed@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/ios/Runner/Assets.xcassets/jinzhi_onetap_button_pressed.imageset/jinzhi_onetap_button_pressed.png b/ios/Runner/Assets.xcassets/jinzhi_onetap_button_pressed.imageset/jinzhi_onetap_button_pressed.png new file mode 100644 index 0000000..1bb4bae Binary files /dev/null and b/ios/Runner/Assets.xcassets/jinzhi_onetap_button_pressed.imageset/jinzhi_onetap_button_pressed.png differ diff --git a/ios/Runner/Assets.xcassets/jinzhi_onetap_button_pressed.imageset/jinzhi_onetap_button_pressed@2x.png b/ios/Runner/Assets.xcassets/jinzhi_onetap_button_pressed.imageset/jinzhi_onetap_button_pressed@2x.png new file mode 100644 index 0000000..aadbfd8 Binary files /dev/null and b/ios/Runner/Assets.xcassets/jinzhi_onetap_button_pressed.imageset/jinzhi_onetap_button_pressed@2x.png differ diff --git a/ios/Runner/Assets.xcassets/jinzhi_onetap_button_pressed.imageset/jinzhi_onetap_button_pressed@3x.png b/ios/Runner/Assets.xcassets/jinzhi_onetap_button_pressed.imageset/jinzhi_onetap_button_pressed@3x.png new file mode 100644 index 0000000..ba29135 Binary files /dev/null and b/ios/Runner/Assets.xcassets/jinzhi_onetap_button_pressed.imageset/jinzhi_onetap_button_pressed@3x.png differ diff --git a/ios/Runner/Assets.xcassets/jinzhi_onetap_checked.imageset/Contents.json b/ios/Runner/Assets.xcassets/jinzhi_onetap_checked.imageset/Contents.json new file mode 100644 index 0000000..adf9b51 --- /dev/null +++ b/ios/Runner/Assets.xcassets/jinzhi_onetap_checked.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"jinzhi_onetap_checked.png","idiom":"universal","scale":"1x"},{"idiom":"universal","scale":"2x"},{"idiom":"universal","scale":"3x"}],"info":{"author":"xcode","version":1}} diff --git a/ios/Runner/Assets.xcassets/jinzhi_onetap_checked.imageset/jinzhi_onetap_checked.png b/ios/Runner/Assets.xcassets/jinzhi_onetap_checked.imageset/jinzhi_onetap_checked.png new file mode 100644 index 0000000..deb090a Binary files /dev/null and b/ios/Runner/Assets.xcassets/jinzhi_onetap_checked.imageset/jinzhi_onetap_checked.png differ diff --git a/ios/Runner/Assets.xcassets/jinzhi_onetap_unchecked.imageset/Contents.json b/ios/Runner/Assets.xcassets/jinzhi_onetap_unchecked.imageset/Contents.json new file mode 100644 index 0000000..92165f0 --- /dev/null +++ b/ios/Runner/Assets.xcassets/jinzhi_onetap_unchecked.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"jinzhi_onetap_unchecked.png","idiom":"universal","scale":"1x"},{"idiom":"universal","scale":"2x"},{"idiom":"universal","scale":"3x"}],"info":{"author":"xcode","version":1}} diff --git a/ios/Runner/Assets.xcassets/jinzhi_onetap_unchecked.imageset/jinzhi_onetap_unchecked.png b/ios/Runner/Assets.xcassets/jinzhi_onetap_unchecked.imageset/jinzhi_onetap_unchecked.png new file mode 100644 index 0000000..1bfff56 Binary files /dev/null and b/ios/Runner/Assets.xcassets/jinzhi_onetap_unchecked.imageset/jinzhi_onetap_unchecked.png differ diff --git a/lib/common/tracking/umeng_service.dart b/lib/common/tracking/umeng_service.dart index 5126676..4d8a20d 100644 --- a/lib/common/tracking/umeng_service.dart +++ b/lib/common/tracking/umeng_service.dart @@ -29,11 +29,17 @@ abstract final class UmengService { static Future _init() async { await DeviceHeaderService.to.ensureInitialized(); - await UmengCommonSdk.initCommon( - UmengConfig.androidAppKey, - UmengConfig.iosAppKey, - DeviceHeaderService.to.commonHeaders()['User-Channel'] ?? 'default', - UmengConfig.androidPushSecret, + // Keep the carrier-login launch path independent of UMeng Common's + // network initialization. Waiting for this future is what left the login + // overlay spinning forever in the base project; U-Verify is configured + // immediately below by initOneKeyLoginIfAllowed, as in Yanting. + unawaited( + UmengCommonSdk.initCommon( + UmengConfig.androidAppKey, + UmengConfig.iosAppKey, + DeviceHeaderService.to.commonHeaders()['User-Channel'] ?? 'default', + UmengConfig.androidPushSecret, + ).catchError((Object _) {}), ); _ready = true; } diff --git a/lib/features/auth/application/login_launcher.dart b/lib/features/auth/application/login_launcher.dart index 28fb4a0..3acd315 100644 --- a/lib/features/auth/application/login_launcher.dart +++ b/lib/features/auth/application/login_launcher.dart @@ -8,10 +8,8 @@ import '../../../common/router/app_router.dart'; import 'auth_controller.dart'; import 'one_key_login_service.dart'; -/// Shared login entry: launch the carrier-owned authorization page first and -/// use the Flutter SMS page only as a deliberate fallback. abstract final class LoginLauncher { - static final _service = OneKeyLoginService(); + static final OneKeyLoginService _service = OneKeyLoginService(); static bool _starting = false; static Future start( @@ -21,71 +19,95 @@ abstract final class LoginLauncher { }) async { if (_starting) return; _starting = true; - final dismissLoading = _showLoading(context); + + var loadingVisible = false; + + void showLoading() { + if (loadingVisible || !context.mounted) return; + loadingVisible = true; + unawaited( + showDialog( + context: context, + useRootNavigator: true, + barrierDismissible: false, + builder: (_) => const PopScope( + canPop: false, + child: Center(child: CircularProgressIndicator()), + ), + ), + ); + } + + void dismissLoading() { + if (!loadingVisible || !context.mounted) return; + loadingVisible = false; + Navigator.of(context, rootNavigator: true).pop(); + } + try { final observer = _OneKeyLoginLifecycleObserver(_service); - WidgetsBinding.instance.addObserver(observer); + var observerAdded = false; late final OneKeyLoginOutcome outcome; try { + WidgetsBinding.instance.addObserver(observer); + observerAdded = true; + // Carrier-auth initialization only begins after this explicit login + // action. Keep the loading indicator visible while the native SDK + // determines whether one-key login can be launched. + showLoading(); outcome = await _service.start( context: context, verifyToken: ref.read(authProvider.notifier).verifyOneKeyLogin, + // The native carrier page follows the device, while linked H5 pages + // retain the appearance selected inside the app. + appIsDark: Theme.of(context).brightness == Brightness.dark, + nativeIsDark: + MediaQuery.platformBrightnessOf(context) == Brightness.dark, onNativeRequestDispatched: dismissLoading, onAuthPageOpened: dismissLoading, ); - } catch (_) { - outcome = OneKeyLoginOutcome.fallback; } finally { - WidgetsBinding.instance.removeObserver(observer); + dismissLoading(); + if (observerAdded) WidgetsBinding.instance.removeObserver(observer); } + if (!context.mounted) return; - if (outcome == OneKeyLoginOutcome.success) { - context.go(next?.isNotEmpty == true ? next! : AppRoutes.profile); - return; - } - if (outcome == OneKeyLoginOutcome.fallback || - outcome == OneKeyLoginOutcome.unsupported) { - final target = next == null || next.isEmpty - ? AppRoutes.login - : '${AppRoutes.login}?next=${Uri.encodeComponent(next)}'; - if (GoRouterState.of(context).matchedLocation != AppRoutes.login) { - await context.push(target); - } + switch (outcome) { + case OneKeyLoginOutcome.success: + if (next != null && next.isNotEmpty) GoRouter.of(context).go(next); + return; + case OneKeyLoginOutcome.cancelled: + case OneKeyLoginOutcome.busy: + return; + case OneKeyLoginOutcome.fallback: + case OneKeyLoginOutcome.unsupported: + await openSmsLogin(context, next: next); } } finally { - dismissLoading(); _starting = false; } } +} - static VoidCallback _showLoading(BuildContext context) { - var visible = true; - unawaited( - showDialog( - context: context, - useRootNavigator: true, - barrierDismissible: false, - builder: (_) => const PopScope( - canPop: false, - child: Center(child: CircularProgressIndicator()), - ), - ), - ); - return () { - if (visible && context.mounted) { - Navigator.of(context, rootNavigator: true).pop(); - } - visible = false; - }; - } +Future openSmsLogin( + BuildContext context, { + String? next, + Future Function()? onReturn, +}) async { + if (!context.mounted) return; + if (GoRouterState.of(context).matchedLocation == AppRoutes.login) return; + final location = next == null || next.isEmpty + ? AppRoutes.login + : '${AppRoutes.login}?next=${Uri.encodeComponent(next)}'; + await GoRouter.of(context).push(location); + if (context.mounted) await onReturn?.call(); } class _OneKeyLoginLifecycleObserver with WidgetsBindingObserver { - _OneKeyLoginLifecycleObserver(this._service); - final OneKeyLoginService _service; - + _OneKeyLoginLifecycleObserver(this.service); + final OneKeyLoginService service; @override void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.resumed) _service.onAppResumed(); + if (state == AppLifecycleState.resumed) service.onAppResumed(); } } diff --git a/lib/features/auth/application/one_key_login_service.dart b/lib/features/auth/application/one_key_login_service.dart index 5a43d21..cfc8f03 100644 --- a/lib/features/auth/application/one_key_login_service.dart +++ b/lib/features/auth/application/one_key_login_service.dart @@ -7,139 +7,286 @@ import 'package:umeng_verify_sdk/umeng_verify_sdk.dart'; import '../../../common/config/app_h5_urls.dart'; import '../../../common/tracking/umeng_service.dart'; +import '../utils/one_key_login_result.dart'; enum OneKeyLoginOutcome { success, fallback, cancelled, unsupported, busy } -/// Runs only after an explicit user login action. Do not add an availability -/// probe or `accelerateLoginPage` outside this class: Xiaomi rejects that as -/// background carrier-auth prefetch. -class OneKeyLoginService { - Future? _operation; - bool _authPageOpened = false; - bool _terminal = false; - Timer? _resumeCancellation; - void Function(OneKeyLoginOutcome)? _completeCurrent; +enum OneKeyLoginPlatform { android, ios, unsupported } - Future start({ - required BuildContext context, - required Future Function(String token) verifyToken, - VoidCallback? onNativeRequestDispatched, - VoidCallback? onAuthPageOpened, - }) { - if (_operation != null) return Future.value(OneKeyLoginOutcome.busy); - _terminal = false; - _authPageOpened = false; - final operation = _start( - context: context, - verifyToken: verifyToken, - onNativeRequestDispatched: onNativeRequestDispatched, - onAuthPageOpened: onAuthPageOpened, - ); - _operation = operation; - return operation.whenComplete(() { - _operation = null; - _completeCurrent = null; - }); +typedef OneKeyLoginCallback = void Function(Object? result); + +abstract interface class OneKeyLoginGateway { + OneKeyLoginPlatform get platform; + + void setTokenCallback(OneKeyLoginCallback? callback); + + void setUiCallback(OneKeyLoginCallback? callback); + + void setWidgetCallback(OneKeyLoginCallback? callback); + + Future requestLogin(UMCustomModel uiConfig); + + void closeAuthPage(); + + void clearCallbacks(); +} + +class UmengOneKeyLoginGateway implements OneKeyLoginGateway { + const UmengOneKeyLoginGateway(); + + @override + OneKeyLoginPlatform get platform { + if (kIsWeb) return OneKeyLoginPlatform.unsupported; + if (Platform.isAndroid) return OneKeyLoginPlatform.android; + if (Platform.isIOS) return OneKeyLoginPlatform.ios; + return OneKeyLoginPlatform.unsupported; } - Future _start({ - required BuildContext context, - required Future Function(String token) verifyToken, - VoidCallback? onNativeRequestDispatched, - VoidCallback? onAuthPageOpened, - }) async { - if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) { - return OneKeyLoginOutcome.unsupported; + @override + void setTokenCallback(OneKeyLoginCallback? callback) { + if (platform == OneKeyLoginPlatform.ios) { + UmengVerifySdk.getLoginTokenCallback(callback); + } else if (platform == OneKeyLoginPlatform.android) { + UmengVerifySdk.setTokenResultCallback_android(callback); } - final uiConfig = _uiConfig(context); - await UmengService.initOneKeyLoginIfAllowed(); - final completer = Completer(); + } - void complete(OneKeyLoginOutcome outcome) { - if (_terminal) return; - _terminal = true; - _resumeCancellation?.cancel(); - _resumeCancellation = null; - _clearCallbacks(); - completer.complete(outcome); + @override + void setUiCallback(OneKeyLoginCallback? callback) { + if (platform == OneKeyLoginPlatform.android) { + UmengVerifySdk.setUIClickCallback_android(callback); } + } - _completeCurrent = complete; - - void handleUiResult(Object? result) { - final code = _code(result); - if (code == '700000') complete(OneKeyLoginOutcome.cancelled); - if (code == '700001') complete(OneKeyLoginOutcome.fallback); + @override + void setWidgetCallback(OneKeyLoginCallback? callback) { + if (platform == OneKeyLoginPlatform.ios) { + UmengVerifySdk.getWidgetEventCallback(callback); } + } - void handleTokenResult(Object? result) { - final code = _code(result); - // iOS 700002 is only the native button click; token follows later. - if (Platform.isIOS && code == '700002') return; - // 600024 is Android environment availability, not a login result. - if (code == '600024') return; - if (code == '600001') { - if (!_authPageOpened) { - _authPageOpened = true; - onAuthPageOpened?.call(); - } - return; - } - if (code == '700000') { - complete(OneKeyLoginOutcome.cancelled); - return; - } - if (code == '700001') { - complete(OneKeyLoginOutcome.fallback); - return; - } - if (code != '600000' || result is! Map) { - complete(OneKeyLoginOutcome.fallback); - return; - } - final token = result['token']?.toString(); - if (token == null || token.isEmpty) { - complete(OneKeyLoginOutcome.fallback); - return; - } - unawaited(() async { - try { - await verifyToken(token); - complete(OneKeyLoginOutcome.success); - } catch (_) { - complete(OneKeyLoginOutcome.fallback); - } finally { - _closeNativePage(); - } - }()); - } + @override + Future requestLogin(UMCustomModel uiConfig) async { + await UmengVerifySdk.getLoginTokenWithTimeout(3, uiConfig); + } - if (Platform.isIOS) { - UmengVerifySdk.getLoginTokenCallback(handleTokenResult); - UmengVerifySdk.getWidgetEventCallback((_) {}); - } else { - UmengVerifySdk.setTokenResultCallback_android(handleTokenResult); - UmengVerifySdk.setUIClickCallback_android(handleUiResult); - } - try { - final request = UmengVerifySdk.getLoginTokenWithTimeout(3, uiConfig); - onNativeRequestDispatched?.call(); + @override + void closeAuthPage() { + if (platform == OneKeyLoginPlatform.ios) { unawaited( - request.catchError((Object _) { - complete(OneKeyLoginOutcome.unsupported); - }), + UmengVerifySdk.cancelLoginVCAnimated( + true, + ).catchError((Object _) => null), ); - return await completer.future.timeout( - const Duration(seconds: 20), - onTimeout: () => OneKeyLoginOutcome.fallback, - ); - } finally { - _clearCallbacks(); + } else if (platform == OneKeyLoginPlatform.android) { + UmengVerifySdk.quitLoginPage_android(); } } + @override + void clearCallbacks() { + UmengVerifySdk.getLoginTokenCallback(null); + UmengVerifySdk.getWidgetEventCallback(null); + UmengVerifySdk.setTokenResultCallback_android(null); + UmengVerifySdk.setUIClickCallback_android(null); + } +} + +class OneKeyLoginSdkResult { + const OneKeyLoginSdkResult({ + required this.outcome, + this.token, + this.code, + this.reason, + }); + + final OneKeyLoginOutcome outcome; + final String? token; + final String? code; + final String? reason; +} + +class OneKeyLoginSdkSession { + OneKeyLoginSdkSession( + this._gateway, { + this.onNativeRequestDispatched, + this.onAuthPageOpened, + }); + + final OneKeyLoginGateway _gateway; + final VoidCallback? onNativeRequestDispatched; + final VoidCallback? onAuthPageOpened; + final _result = Completer(); + var _started = false; + var _terminal = false; + var _closed = false; + var _authPageOpened = false; + Timer? _resumeCancellation; + + Future start(UMCustomModel uiConfig) { + if (_started) return _result.future; + _started = true; + + if (_gateway.platform == OneKeyLoginPlatform.unsupported) { + _complete( + const OneKeyLoginSdkResult( + outcome: OneKeyLoginOutcome.unsupported, + reason: 'unsupported_platform', + ), + ); + return _result.future; + } + + // Availability checking already pre-accelerates the carrier page. Start + // the token request directly here; the SDK performs its own preflight when + // needed and reports its timeout/error through the registered callback. + unawaited(_startNativeLogin(uiConfig)); + return _result.future; + } + + Future _startNativeLogin(UMCustomModel uiConfig) async { + try { + _gateway.setTokenCallback(_handleTokenResult); + _gateway.setWidgetCallback((Object? _) {}); + _gateway.setUiCallback(_handleUiResult); + final nativeRequest = _gateway.requestLogin(uiConfig); + onNativeRequestDispatched?.call(); + await nativeRequest; + } catch (_) { + final completed = _complete( + const OneKeyLoginSdkResult( + outcome: OneKeyLoginOutcome.unsupported, + reason: 'launch_error', + ), + ); + if (completed) close(); + } + } + + void _handleUiResult(Object? result) { + final code = extractUmengLoginCode(result); + if (isUmengCancel(result)) { + _complete( + OneKeyLoginSdkResult( + outcome: OneKeyLoginOutcome.cancelled, + code: code, + reason: 'cancel', + ), + ); + } else if (isUmengManualSwitch(result)) { + _complete( + OneKeyLoginSdkResult( + outcome: OneKeyLoginOutcome.fallback, + code: code, + reason: 'manual_switch', + ), + ); + } + } + + void _handleTokenResult(Object? result) { + final code = extractUmengLoginCode(result); + // On iOS, 700002 only means the user tapped the authorization page's + // login button. It is followed by a separate 600000 (or 600xxx) token + // callback, so completing the session here discards that callback. + if (_gateway.platform == OneKeyLoginPlatform.ios && code == '700002') { + return; + } + // Android `checkEnvAvailable_android` reports 600024 through the same + // token callback. It means the terminal supports authentication, not that + // this login attempt failed; wait for the actual login callback instead. + if (isUmengEnvironmentAvailable(result)) { + return; + } + if (isUmengAuthPage(result)) { + if (_authPageOpened) return; + _authPageOpened = true; + onAuthPageOpened?.call(); + return; + } + if (isUmengCancel(result)) { + _complete( + OneKeyLoginSdkResult( + outcome: OneKeyLoginOutcome.cancelled, + code: code, + reason: 'cancel', + ), + ); + return; + } + if (isUmengManualSwitch(result)) { + _complete( + OneKeyLoginSdkResult( + outcome: OneKeyLoginOutcome.fallback, + code: code, + reason: 'manual_switch', + ), + ); + return; + } + if (isUmengFallbackLoginCode(result)) { + _complete( + OneKeyLoginSdkResult( + outcome: OneKeyLoginOutcome.fallback, + code: code, + reason: 'sdk_fallback', + ), + ); + return; + } + if (!isUmengTokenSuccess(result) || result is! Map) { + _complete( + OneKeyLoginSdkResult( + outcome: OneKeyLoginOutcome.fallback, + code: code, + reason: 'token_failed', + ), + ); + return; + } + + final token = result['token']?.toString(); + if (token == null || token.isEmpty) { + _complete( + OneKeyLoginSdkResult( + outcome: OneKeyLoginOutcome.fallback, + code: code, + reason: 'token_empty', + ), + ); + return; + } + _complete( + OneKeyLoginSdkResult( + outcome: OneKeyLoginOutcome.success, + token: token, + code: code, + ), + ); + } + + bool _complete(OneKeyLoginSdkResult result) { + if (_terminal) return false; + _terminal = true; + _resumeCancellation?.cancel(); + _resumeCancellation = null; + _gateway.clearCallbacks(); + _result.complete(result); + return true; + } + + void cancel() { + _complete( + const OneKeyLoginSdkResult( + outcome: OneKeyLoginOutcome.cancelled, + reason: 'disposed', + ), + ); + close(); + } + void onAppResumed() { - if (!Platform.isAndroid || + if (_gateway.platform != OneKeyLoginPlatform.android || !_authPageOpened || _terminal || _resumeCancellation != null) { @@ -147,72 +294,263 @@ class OneKeyLoginService { } _resumeCancellation = Timer(const Duration(milliseconds: 500), () { _resumeCancellation = null; - if (_terminal) return; - _completeCurrent?.call(OneKeyLoginOutcome.cancelled); - _closeNativePage(); + final completed = _complete( + const OneKeyLoginSdkResult( + outcome: OneKeyLoginOutcome.cancelled, + reason: 'auth_page_returned', + ), + ); + if (completed) close(); }); } - UMCustomModel _uiConfig(BuildContext context) { - const ink = Color(0xFF202124); - const muted = Color(0xFF8A8F98); - const background = Color(0xFFFFFFFF); - final model = UMCustomModel() - ..logoIsHidden = true - ..numberColor = ink.toARGB32() - ..numberFont = 30 - ..navTitle = ['', Colors.transparent.toARGB32(), 0] - ..navColor = background.toARGB32() - ..backgroundColor_ios = background.toARGB32() - ..sloganText = ['', background.toARGB32(), 0] - ..privacyPreText = '我已阅读并同意' - ..privacyOne = ['《隐私政策》', AppH5Urls.privacyUrl] - ..privacyTwo = ['《用户协议》', AppH5Urls.userProtocolUrl] - ..privacyColors = [muted.toARGB32(), ink.toARGB32()] - ..changeBtnTitle = ['其他手机号登录', ink.toARGB32(), 16] - ..loginBtnText = ['本机号码一键登录', Colors.white.toARGB32(), 16] - ..privacyFont = 12 - ..checkBoxIsChecked = false; - if (Platform.isAndroid) { - model.numberFrame = [-1, 156, -1, -1]; - model.loginBtnFrame = [-1, 283, -1, -1, 52]; - model.changeBtnFrame = [368, -1, -1, -1]; - model.privacyFrame = [-1, 427, -1, -1]; - } else { - final title = UMCustomWidget('title', UMCustomWidgetType.textView) - ..title = '金值' - ..titleFont = 26 - ..titleColor = ink.toARGB32() - ..width = MediaQuery.sizeOf(context).width.toInt() - ..height = 54 - ..top = 26 - ..textAlignment = UMCustomWidgetTextAlignmentType.center - ..isClickEnable = false - ..rootViewId = UMRootViewId.body; - model.customWidget = [title.toJsonMap()]; - model.privacyFont = 13; - model.checkBoxWH = 20; - model.checkBoxIsChecked = true; - } - return model; - } - - String _code(Object? result) => result is Map - ? (result['code'] ?? result['resultCode'] ?? '').toString() - : ''; - - void _clearCallbacks() { - UmengVerifySdk.getLoginTokenCallback(null); - UmengVerifySdk.getWidgetEventCallback(null); - UmengVerifySdk.setTokenResultCallback_android(null); - UmengVerifySdk.setUIClickCallback_android(null); - } - - void _closeNativePage() { - if (Platform.isIOS) { - unawaited(UmengVerifySdk.cancelLoginVCAnimated(true)); - } else { - UmengVerifySdk.quitLoginPage_android(); - } + void close() { + if (_closed) return; + _closed = true; + _gateway.closeAuthPage(); } } + +class OneKeyLoginService { + OneKeyLoginService({ + OneKeyLoginGateway gateway = const UmengOneKeyLoginGateway(), + Future Function() initializeSdk = + UmengService.initOneKeyLoginIfAllowed, + }) : _gateway = gateway, + _initializeSdk = initializeSdk; + + final OneKeyLoginGateway _gateway; + final Future Function() _initializeSdk; + OneKeyLoginSdkSession? _activeSession; + Future? _activeOperation; + _OneKeyLoginPhase _phase = _OneKeyLoginPhase.idle; + int _generation = 0; + + Future start({ + required BuildContext context, + required Future Function(String loginToken) verifyToken, + required bool appIsDark, + required bool nativeIsDark, + VoidCallback? onNativeRequestDispatched, + VoidCallback? onAuthPageOpened, + }) { + if (_phase == _OneKeyLoginPhase.authPageVisible || + _phase == _OneKeyLoginPhase.verifying) { + return Future.value(OneKeyLoginOutcome.busy); + } + + if (_phase == _OneKeyLoginPhase.launching) { + _activeSession?.cancel(); + _activeSession = null; + } + + final generation = ++_generation; + _phase = _OneKeyLoginPhase.launching; + final operation = _start( + generation: generation, + context: context, + verifyToken: verifyToken, + appIsDark: appIsDark, + nativeIsDark: nativeIsDark, + onNativeRequestDispatched: () { + if (generation != _generation) return; + onNativeRequestDispatched?.call(); + }, + onAuthPageOpened: () { + if (generation != _generation) return; + _phase = _OneKeyLoginPhase.authPageVisible; + onAuthPageOpened?.call(); + }, + ); + _activeOperation = operation; + return operation.whenComplete(() { + if (generation == _generation && identical(_activeOperation, operation)) { + _activeOperation = null; + _phase = _OneKeyLoginPhase.idle; + } + }); + } + + Future _start({ + required int generation, + required BuildContext context, + required Future Function(String loginToken) verifyToken, + required bool appIsDark, + required bool nativeIsDark, + VoidCallback? onNativeRequestDispatched, + VoidCallback? onAuthPageOpened, + }) async { + if (_gateway.platform == OneKeyLoginPlatform.unsupported) { + return OneKeyLoginOutcome.unsupported; + } + + await _initializeSdk(); + if (generation != _generation) return OneKeyLoginOutcome.cancelled; + if (!context.mounted) return OneKeyLoginOutcome.cancelled; + final uiConfig = _buildUiConfig(context, appIsDark, nativeIsDark); + final session = OneKeyLoginSdkSession( + _gateway, + onNativeRequestDispatched: onNativeRequestDispatched, + onAuthPageOpened: onAuthPageOpened, + ); + _activeSession = session; + try { + final sdkResult = await session.start(uiConfig); + if (!context.mounted) { + session.close(); + return OneKeyLoginOutcome.cancelled; + } + if (sdkResult.outcome != OneKeyLoginOutcome.success) { + session.close(); + return sdkResult.outcome; + } + + final token = sdkResult.token; + if (token == null || token.isEmpty) { + session.close(); + return OneKeyLoginOutcome.fallback; + } + + if (generation == _generation) { + _phase = _OneKeyLoginPhase.verifying; + } + await verifyToken(token); + session.close(); + return OneKeyLoginOutcome.success; + } catch (_) { + session.close(); + return OneKeyLoginOutcome.fallback; + } finally { + if (identical(_activeSession, session)) { + _activeSession = null; + } + } + } + + void cancel() { + _generation++; + _activeSession?.cancel(); + _activeSession = null; + _activeOperation = null; + _phase = _OneKeyLoginPhase.idle; + } + + void onAppResumed() { + _activeSession?.onAppResumed(); + } + + UMCustomModel _buildUiConfig( + BuildContext context, + bool appIsDark, + bool nativeIsDark, + ) { + final width = MediaQuery.of(context).size.width; + // UVerify follows Android's system theme, which can differ from Flutter's + // active theme. iOS colors are fixed at launch, so it receives a palette + // resolved from the device appearance. + final useDarkPalette = + _gateway.platform == OneKeyLoginPlatform.ios && nativeIsDark; + final pageBg = useDarkPalette ? const Color(0xFF0F0F0F) : Colors.white; + final titleInk = useDarkPalette + ? const Color(0xFFF0F0F0) + : const Color(0xFF252529); + final hintInk = useDarkPalette + ? const Color(0xFFA1A1AA) + : const Color(0xFF8E8E93); + const buttonInk = Color(0xFF0F1A00); + final transparent = Colors.transparent.toARGB32(); + final titleColor = titleInk.toARGB32(); + final hintColor = hintInk.toARGB32(); + final buttonTextColor = buttonInk.toARGB32(); + final bgColor = pageBg.toARGB32(); + final protocolUrl = AppH5UrlsHelper.buildUrlWithNight( + AppH5Urls.userProtocolUrl, + appIsDark, + ); + final privacyUrl = AppH5UrlsHelper.buildUrlWithNight( + AppH5Urls.privacyUrl, + appIsDark, + ); + + final model = UMCustomModel(); + final titleWidget = UMCustomWidget('title', UMCustomWidgetType.textView) + ..title = '金值' + ..titleFont = 28 + ..textAlignment = UMCustomWidgetTextAlignmentType.center + ..top = 26 + ..width = width.toInt() + ..height = 54 + ..titleColor = titleColor + ..isClickEnable = false + ..rootViewId = UMRootViewId.body; + + model.logoIsHidden = true; + model.numberColor = titleColor; + model.numberFont = 32; + model.sloganText = ['', bgColor, 0]; + model.navTitle = ['', transparent, 0]; + model.privacyPreText = '我已阅读并同意'; + model.privacyOne = ['《隐私政策》', privacyUrl]; + model.privacyTwo = ['《用户协议》', protocolUrl]; + model.privacyColors = [hintColor, titleColor]; + model.changeBtnTitle = ['其他手机号登录', titleColor, 16]; + model.privacyOperatorPreText = '《'; + model.privacyOperatorSufText = '》'; + model.autoHideLoginLoading = false; + model.customWidget = [titleWidget.toJsonMap()]; + + if (_gateway.platform == OneKeyLoginPlatform.ios) { + model.loginBtnText = ['本机号码一键登录', buttonTextColor, 20]; + model.loginBtnBgImgs_ios = [ + 'jinzhi_onetap_button', + 'jinzhi_onetap_button', + 'jinzhi_onetap_button_pressed', + ]; + model.backgroundColor_ios = bgColor; + model.navColor = bgColor; + // Always use our paired assets so both appearances keep the same iOS + // chevron geometry and differ only in color. + model.navBackImage = useDarkPalette + ? 'jinzhi_onetap_back_night' + : 'jinzhi_onetap_back_day'; + model.privacyConectTexts = ['和', '']; + model.privacyFont = 13; + model.checkBoxImages = [ + 'jinzhi_onetap_unchecked', + 'jinzhi_onetap_checked', + ]; + // 原生 SDK 会按该尺寸直接放大图片;36pt 会让协议勾选显得过重。 + model.checkBoxWH = 22; + model.checkBoxIsChecked = true; + } else { + model.numberFrame = [-1, 156, -1, -1]; + // Keep the same Android SDK frame as caizhi. The capsule's visible + // geometry is determined by the native drawable's intrinsic size. + model.loginBtnFrame = [-1, 283, -1, -1, 56]; + model.changeBtnFrame = [368, -1, -1, -1]; + model.privacyFrame = [-1, 427, -1, -1]; + // Android only accepts width/height here. The back drawable itself uses + // caizhi's 48×120 transparent canvas to position the arrow correctly. + model.navBackButtonFrame = [-1, -1, 72, 120]; + model.loginBtnText = ['本机号码一键登录', buttonTextColor, 16]; + model.loginBtnBgImg_android = 'jinzhi_onetap_button'; + model.navBackImage = 'jinzhi_onetap_back_daynight'; + model.navColor = transparent; + model.privacyFont = 12; + model.privacyConectTexts = ['和', '、']; + model.checkBoxWH = 14; + model.checkBoxIsChecked = false; + model.checkBoxImages = [ + 'jinzhi_onetap_unchecked', + 'jinzhi_onetap_checked', + ]; + model.privacyNavColor = bgColor; + model.privacyNavTitleColor = titleColor; + model.privacyNavTitleFont = 16; + } + + return model; + } +} + +enum _OneKeyLoginPhase { idle, launching, authPageVisible, verifying } diff --git a/lib/features/auth/utils/one_key_login_result.dart b/lib/features/auth/utils/one_key_login_result.dart new file mode 100644 index 0000000..bf2c395 --- /dev/null +++ b/lib/features/auth/utils/one_key_login_result.dart @@ -0,0 +1,40 @@ +const _umengFallbackCodes = { + '600002', + '600004', + '600005', + '600007', + '600008', + '600009', + '600010', + '600011', + '600012', + '600013', + '600014', + '600015', + '600017', + '600021', + '600023', + '600025', + '600026', +}; + +String? extractUmengLoginCode(Object? result) { + if (result is! Map) return null; + final code = result['code'] ?? result['resultCode']; + final value = code?.toString(); + return value == null || value.isEmpty ? null : value; +} + +bool isUmengTokenSuccess(Object? result) => + extractUmengLoginCode(result) == '600000'; +bool isUmengAuthPage(Object? result) => + extractUmengLoginCode(result) == '600001'; +bool isUmengEnvironmentAvailable(Object? result) => + extractUmengLoginCode(result) == '600024'; +bool isUmengCancel(Object? result) => extractUmengLoginCode(result) == '700000'; +bool isUmengManualSwitch(Object? result) => + extractUmengLoginCode(result) == '700001'; +bool isUmengFallbackLoginCode(Object? result) { + final code = extractUmengLoginCode(result); + return code != null && _umengFallbackCodes.contains(code); +}