557 lines
16 KiB
Dart
557 lines
16 KiB
Dart
import 'dart:async';
|
||
import 'dart:io';
|
||
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter/material.dart';
|
||
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 }
|
||
|
||
enum OneKeyLoginPlatform { android, ios, unsupported }
|
||
|
||
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<void> 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;
|
||
}
|
||
|
||
@override
|
||
void setTokenCallback(OneKeyLoginCallback? callback) {
|
||
if (platform == OneKeyLoginPlatform.ios) {
|
||
UmengVerifySdk.getLoginTokenCallback(callback);
|
||
} else if (platform == OneKeyLoginPlatform.android) {
|
||
UmengVerifySdk.setTokenResultCallback_android(callback);
|
||
}
|
||
}
|
||
|
||
@override
|
||
void setUiCallback(OneKeyLoginCallback? callback) {
|
||
if (platform == OneKeyLoginPlatform.android) {
|
||
UmengVerifySdk.setUIClickCallback_android(callback);
|
||
}
|
||
}
|
||
|
||
@override
|
||
void setWidgetCallback(OneKeyLoginCallback? callback) {
|
||
if (platform == OneKeyLoginPlatform.ios) {
|
||
UmengVerifySdk.getWidgetEventCallback(callback);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Future<void> requestLogin(UMCustomModel uiConfig) async {
|
||
await UmengVerifySdk.getLoginTokenWithTimeout(3, uiConfig);
|
||
}
|
||
|
||
@override
|
||
void closeAuthPage() {
|
||
if (platform == OneKeyLoginPlatform.ios) {
|
||
unawaited(
|
||
UmengVerifySdk.cancelLoginVCAnimated(
|
||
true,
|
||
).catchError((Object _) => null),
|
||
);
|
||
} 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<OneKeyLoginSdkResult>();
|
||
var _started = false;
|
||
var _terminal = false;
|
||
var _closed = false;
|
||
var _authPageOpened = false;
|
||
Timer? _resumeCancellation;
|
||
|
||
Future<OneKeyLoginSdkResult> 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<void> _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 (_gateway.platform != OneKeyLoginPlatform.android ||
|
||
!_authPageOpened ||
|
||
_terminal ||
|
||
_resumeCancellation != null) {
|
||
return;
|
||
}
|
||
_resumeCancellation = Timer(const Duration(milliseconds: 500), () {
|
||
_resumeCancellation = null;
|
||
final completed = _complete(
|
||
const OneKeyLoginSdkResult(
|
||
outcome: OneKeyLoginOutcome.cancelled,
|
||
reason: 'auth_page_returned',
|
||
),
|
||
);
|
||
if (completed) close();
|
||
});
|
||
}
|
||
|
||
void close() {
|
||
if (_closed) return;
|
||
_closed = true;
|
||
_gateway.closeAuthPage();
|
||
}
|
||
}
|
||
|
||
class OneKeyLoginService {
|
||
OneKeyLoginService({
|
||
OneKeyLoginGateway gateway = const UmengOneKeyLoginGateway(),
|
||
Future<void> Function() initializeSdk =
|
||
UmengService.initOneKeyLoginIfAllowed,
|
||
}) : _gateway = gateway,
|
||
_initializeSdk = initializeSdk;
|
||
|
||
final OneKeyLoginGateway _gateway;
|
||
final Future<void> Function() _initializeSdk;
|
||
OneKeyLoginSdkSession? _activeSession;
|
||
Future<OneKeyLoginOutcome>? _activeOperation;
|
||
_OneKeyLoginPhase _phase = _OneKeyLoginPhase.idle;
|
||
int _generation = 0;
|
||
|
||
Future<OneKeyLoginOutcome> start({
|
||
required BuildContext context,
|
||
required Future<void> Function(String loginToken) verifyToken,
|
||
required bool appIsDark,
|
||
required bool nativeIsDark,
|
||
VoidCallback? onNativeRequestDispatched,
|
||
VoidCallback? onAuthPageOpened,
|
||
}) {
|
||
if (_phase == _OneKeyLoginPhase.authPageVisible ||
|
||
_phase == _OneKeyLoginPhase.verifying) {
|
||
return Future<OneKeyLoginOutcome>.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<OneKeyLoginOutcome> _start({
|
||
required int generation,
|
||
required BuildContext context,
|
||
required Future<void> 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 }
|