import 'dart:async'; import 'package:dio/dio.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:guru_app/account/account_data_store.dart'; import 'package:guru_app/account/model/account.dart'; import 'package:guru_app/account/model/account_profile.dart'; import 'package:guru_app/account/model/user.dart'; import 'package:guru_app/analytics/guru_analytics.dart'; import 'package:guru_app/api/guru_api.dart'; import 'package:guru_app/firebase/firebase.dart'; import 'package:guru_app/firebase/firestore/firestore_manager.dart'; import 'package:guru_app/guru_app.dart'; import 'package:guru_app/property/app_property.dart'; import 'package:guru_app/property/settings/guru_settings.dart'; import 'package:guru_utils/auth/auth_credential_manager.dart'; import 'package:guru_utils/collection/collectionutils.dart'; import 'package:guru_utils/core/ext.dart'; import 'package:guru_utils/datetime/datetime_utils.dart'; import 'package:guru_utils/device/device_info.dart'; import 'package:guru_utils/device/device_utils.dart'; import 'package:guru_utils/log/log.dart'; import 'package:guru_utils/network/network_utils.dart'; import 'model/credential.dart'; /// Created by Haoyi on 6/3/21 /// /// part "account_service_extension.dart"; part "account_auth_extension.dart"; part "account_auth_invoker.dart"; class ModifyNicknameException implements Exception { final String? message; final dynamic cause; ModifyNicknameException(this.message, {this.cause}); @override String toString() { return "ModifyNicknameException: $message cause:$cause"; } } class ModifyLevelException implements Exception { final String? message; final dynamic cause; ModifyLevelException(this.message, {this.cause}); @override String toString() { return "ModifyLevelException: $message cause:$cause"; } } class AccountManager { final AccountDataStore accountDataStore; Timer? retryTimer; static final AccountManager instance = AccountManager(); static const List defaultSupportedAuthCredentialDelegates = [ AnonymousCredentialDelegate() ]; AccountManager() : accountDataStore = AccountDataStore.instance; Future init({Completer? completer}) async { try { final result = accountDataStore.transitionTo(AccountDataStatus.initializing); if (!result) { Log.w( "init account error, current initializing! please wait result! retry[${accountDataStore.initRetryCount}]", tag: "Account"); return; } retryTimer?.cancel(); final account = await AppProperty.getInstance().loadAccount(); final restoreResult = await _restoreAccount(account); if (!restoreResult) { Log.v("init account error: restoreAccount error! retry[${accountDataStore.initRetryCount}]", tag: "Account"); _retry(); } else { accountDataStore.initRetryCount = 0; accountDataStore.transitionTo(AccountDataStatus.initialized); Log.v("init account success!", tag: "Account"); } } catch (error, stacktrace) { completer?.complete(error); Log.v("init account error retry[${accountDataStore.initRetryCount}]:$error $stacktrace", tag: "Account"); _retry(); } completer?.complete(true); } void _retry() { final intervalSeconds = (accountDataStore.initRetryCount * 2 + 8).clamp(8, 30); retryTimer?.cancel(); accountDataStore.transitionTo(AccountDataStatus.waiting); retryTimer = Timer(Duration(seconds: intervalSeconds), () { init(); accountDataStore.initRetryCount++; }); } Future updateLocalProfile(Map modifiedJson) async { modifiedJson[AccountProfile.dirtyField] = true; final dirtyAccountProfile = accountDataStore.accountProfile?.merge(modifiedJson) ?? AccountProfile.fromJson(modifiedJson); AppProperty.getInstance().setAccountProfile(dirtyAccountProfile); accountDataStore.updateAccountProfile(dirtyAccountProfile); } /// 登录 /// /// [authType] 登录类型 /// [onConflict] 登录冲突处理 /// [onLogin] 登录成功处理 /// Future loginWith(AuthType authType) async { late final Credential? credential; try { final result = await AuthCredentialManager.instance.loginWith(authType); credential = result.credential; if (!result.isSuccess || credential == null) { Log.w("loginWith $authType error! credential: [$credential]", tag: "Account"); return false; } } catch (error, stacktrace) { Log.e("loginWith $authType error:$error, $stacktrace"); return false; } try { /// 如果冲突将会报 409 的错 final guruUser = await _requestGuruUser(credential); await processLogin(guruUser, credential); return true; } catch (error, stacktrace) { Log.w("loginWith $authType error:$error, $stacktrace"); if (error is DioError && error.response?.statusCode == 409) { return await _processConflict(credential); } else { return false; } } } Future processLogin(GuruUser user, Credential credential) async { await _updateGuruUser(user); await _bindCredential(credential); try { await _verifyOrReportAuthDevice(user); authenticateFirebase(); } catch (error, stacktrace) { Log.e("_verifyOrReportAuthDevice error!$error $stacktrace"); } if (credential.isAnonymous) { return await _invokeAnonymousLogin(user, credential); } else { return await _invokeLogin(user, credential); } } /// /// 登出操作,会把所有的第三方登陆都登出,如果当前有对应的匿名登陆的 credential 那么保留到匿名登陆的状态 /// 如果当前没有匿名登录的 credential 那么这里将会重新创建一个匿名登陆,并且这里不会进行数据迁移 /// /// 如果在调用 logout 时,明确指定了要登出哪些 AuthType, /// 那么 logout 方法将尝试用 unbind 的方法去处理用户信息。 /// 当尝试解绑掉指定的 authTypes 时,只要满足下面两种情况的其中一种, /// 都不能以 unbind 形式进行处理,都会认定为是真正的 logout /// 1. 如果解绑掉所有指定的凭证后,当前凭证信息只保留了一个匿名凭证 /// 2. 如果解绑掉所有指定的凭证后,当前没有任何凭证信息 /// 如果上面两个条件都不满足,那么将以 unbind方法进行 logout /// 以 unbind 形式进行 logout时,将不会通知应用 onLogout方法 /// /// 因此这里需要注意,就算明确指定了登出的 AuthType,依然存在调用 onLogout 的情况 /// Future logout({bool switching = false, Set? authTypes}) async { bool isUnbind = false; if (authTypes != null && authTypes.isNotEmpty) { final currentCredentials = accountDataStore.credentials.keys.toSet(); currentCredentials.removeAll(authTypes); currentCredentials.remove(AuthType.anonymous); isUnbind = currentCredentials.isNotEmpty; } final logoutUser = accountDataStore.user?.copyWith(); try { if (!isUnbind && logoutUser != null) { final result = await _invokeLogout(logoutUser); if (!result) { Log.w("logout error! ignore!"); return null; } } } catch (error, stacktrace) { Log.w("invokeLogout error! $error!"); return null; } for (var authType in accountDataStore.credentials.keys) { /// 默认的登出只是 unbind 掉三方的 credentials ,不会真正的登出 /// 如果当前没有匿名登陆,那么就会真正的登出,并会重新登陆匿名,但是数据不会清除 /// 如果 authTypes传的是 null,这里会返回空,依然满足不等于 False, /// 这里只要是空或是真正的包含才会进行真正的解绑 if (authTypes?.contains(authType) != false && authType != AuthType.anonymous) { await AuthCredentialManager.instance.logout(authType); _unbindCredential(authType); } } /// 如果当前连匿名登陆也没有了,那么就会重新登陆匿名帐号 /// 如果是正在切换帐号的话,这里不需要登录一个新的匿名帐号 if (!switching && accountDataStore.credentials.isEmpty) { final auth = await _retrieveAnonymous(); if (auth != null) { await processLogin(auth.user, auth.credential!); } } return logoutUser; } Future modifyProfile( {String? nickname, String? avatar, String? countryCode, Map userData = const {}}) async { int retryCount = 2; Log.i("modifyProfile $nickname $avatar $countryCode", syncFirebase: true, tag: "Account"); if (nickname == null && avatar == null && countryCode == null && userData.isEmpty) { return false; } final now = DateTimeUtils.currentTimeInMillis(); final modifiedJson = CollectionUtils.filterOutNulls({ AccountProfile.uidField: accountDataStore.uid, AccountProfile.nicknameField: nickname, AccountProfile.countryField: countryCode?.toLowerCase(), AccountProfile.avatarField: avatar, AccountProfile.updateAtField: now, AccountProfile.versionField: GuruSettings.instance.version.get(), AccountProfile.roleField: GuruSettings.instance.debugMode.get() == true ? UserAttr.tester : UserAttr.real, AccountProfile.dirtyField: true, ...userData }); await updateLocalProfile(modifiedJson); /// 如果本地部署没有打开同步 AccountProfile 机制,这里直接返回 true if (!GuruApp.instance.appSpec.deployment.enabledSyncAccountProfile) { return true; } while ((await NetworkUtils.isNetworkConnected()) && retryCount-- > 0) { final accountProfile = await FirestoreManager.instance.modifyProfile(modifiedJson).onError((error, stackTrace) { Log.i("modifyProfile error!:$error"); GuruAnalytics.instance.logException(ModifyLevelException("modifyProfile error!:$error"), stacktrace: stackTrace); return null; }); if (accountProfile != null) { Log.i("modifyProfile success! $accountProfile", tag: "Account"); AppProperty.getInstance().setAccountProfile(accountProfile); accountDataStore.updateAccountProfile(accountProfile); return true; } else { Log.i("[$retryCount] modify profile error!", tag: "Account"); await authenticateFirebase() .timeout(const Duration(seconds: 15)) .catchError((error, stackTrace) { Log.i("re-authenticate error:$error", stackTrace: stackTrace, tag: "Account"); }); await Future.delayed(const Duration(seconds: 1)); } } return false; } }