288 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Dart
		
	
	
			
		
		
	
	
			288 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Dart
		
	
	
| 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<AuthCredentialDelegate> defaultSupportedAuthCredentialDelegates = [
 | ||
|     AnonymousCredentialDelegate()
 | ||
|   ];
 | ||
| 
 | ||
|   AccountManager() : accountDataStore = AccountDataStore.instance;
 | ||
| 
 | ||
|   Future init({Completer<dynamic>? 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<String, dynamic> 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<bool> 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<bool> 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<GuruUser?> logout({bool switching = false, Set<AuthType>? 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<bool> modifyProfile(
 | ||
|       {String? nickname,
 | ||
|       String? avatar,
 | ||
|       String? countryCode,
 | ||
|       Map<String, dynamic> userData = const <String, dynamic>{}}) 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(<String, dynamic>{
 | ||
|       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;
 | ||
|   }
 | ||
| }
 |