guru_sdk/guru_app/lib/account/account_manager.dart

288 lines
11 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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;
}
}