added support for multiple accounts, guardian login bugfixes, ui changes
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'session.dart';
|
||||
import 'session_codec.dart';
|
||||
|
||||
/// One signed-in account on this device. The session itself lives in the
|
||||
/// keychain (active slots or the account's vault); this is the index entry.
|
||||
class AccountEntry {
|
||||
final String id;
|
||||
final String kind;
|
||||
|
||||
/// Username or e-mail — the identity of the account.
|
||||
final String label;
|
||||
|
||||
/// Real name as known to the server; null until it was loaded once.
|
||||
final String? displayName;
|
||||
final bool isDemo;
|
||||
|
||||
/// Storage namespace of the account's local data. Empty for the account that
|
||||
/// existed before multi-account support, so its data stays where it was.
|
||||
final String namespace;
|
||||
final int lastUsed;
|
||||
|
||||
const AccountEntry({
|
||||
required this.id,
|
||||
required this.kind,
|
||||
required this.label,
|
||||
required this.namespace,
|
||||
this.displayName,
|
||||
this.isDemo = false,
|
||||
this.lastUsed = 0,
|
||||
});
|
||||
|
||||
bool get isGuardian => kind == SessionKeys.kindGuardian;
|
||||
|
||||
AccountEntry copyWith({int? lastUsed, String? displayName}) => AccountEntry(
|
||||
id: id,
|
||||
kind: kind,
|
||||
label: label,
|
||||
namespace: namespace,
|
||||
displayName: displayName ?? this.displayName,
|
||||
isDemo: isDemo,
|
||||
lastUsed: lastUsed ?? this.lastUsed,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'kind': kind,
|
||||
'label': label,
|
||||
'namespace': namespace,
|
||||
'displayName': displayName,
|
||||
'isDemo': isDemo,
|
||||
'lastUsed': lastUsed,
|
||||
};
|
||||
|
||||
static AccountEntry? fromJson(Object? json) {
|
||||
if (json is! Map) return null;
|
||||
final id = json['id'];
|
||||
final kind = json['kind'];
|
||||
final label = json['label'];
|
||||
if (id is! String || kind is! String || label is! String) return null;
|
||||
return AccountEntry(
|
||||
id: id,
|
||||
kind: kind,
|
||||
label: label,
|
||||
namespace: json['namespace'] is String ? json['namespace'] as String : id,
|
||||
displayName: json['displayName'] is String
|
||||
? json['displayName'] as String
|
||||
: null,
|
||||
isDemo: json['isDemo'] == true,
|
||||
lastUsed: json['lastUsed'] is int ? json['lastUsed'] as int : 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AccountIndex {
|
||||
final List<AccountEntry> accounts;
|
||||
final String? activeId;
|
||||
|
||||
const AccountIndex({this.accounts = const [], this.activeId});
|
||||
|
||||
static const empty = AccountIndex();
|
||||
|
||||
AccountEntry? get active => byId(activeId);
|
||||
|
||||
AccountEntry? byId(String? id) {
|
||||
if (id == null) return null;
|
||||
for (final entry in accounts) {
|
||||
if (entry.id == id) return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The entry [session] belongs to — same kind, identity and demo flag.
|
||||
AccountEntry? matching(Session session) {
|
||||
final (kind, label) = identityOf(session);
|
||||
for (final entry in accounts) {
|
||||
if (entry.kind == kind &&
|
||||
entry.label == label &&
|
||||
entry.isDemo == session.isDemo) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Makes [session] the active account, reusing its entry when it is already
|
||||
/// known and otherwise adding one with [newId] (also its namespace).
|
||||
AccountIndex activate(
|
||||
Session session, {
|
||||
required String newId,
|
||||
String? namespace,
|
||||
int now = 0,
|
||||
}) {
|
||||
final existing = matching(session);
|
||||
if (existing != null) return select(existing.id, now: now);
|
||||
final (kind, label) = identityOf(session);
|
||||
return AccountIndex(
|
||||
accounts: [
|
||||
...accounts,
|
||||
AccountEntry(
|
||||
id: newId,
|
||||
kind: kind,
|
||||
label: label,
|
||||
namespace: namespace ?? newId,
|
||||
isDemo: session.isDemo,
|
||||
lastUsed: now,
|
||||
),
|
||||
],
|
||||
activeId: newId,
|
||||
);
|
||||
}
|
||||
|
||||
AccountIndex select(String id, {int now = 0}) => AccountIndex(
|
||||
accounts: accounts
|
||||
.map((e) => e.id == id ? e.copyWith(lastUsed: now) : e)
|
||||
.toList(),
|
||||
activeId: id,
|
||||
);
|
||||
|
||||
AccountIndex rename(String id, String displayName) => AccountIndex(
|
||||
accounts: accounts
|
||||
.map((e) => e.id == id ? e.copyWith(displayName: displayName) : e)
|
||||
.toList(),
|
||||
activeId: activeId,
|
||||
);
|
||||
|
||||
/// Drops [id]; the active account is left unset when it was the one removed.
|
||||
AccountIndex remove(String id) => AccountIndex(
|
||||
accounts: [
|
||||
for (final e in accounts)
|
||||
if (e.id != id) e,
|
||||
],
|
||||
activeId: activeId == id ? null : activeId,
|
||||
);
|
||||
|
||||
/// Most recently used account other than [excludedId], if any.
|
||||
AccountEntry? mostRecentExcept(String? excludedId) {
|
||||
AccountEntry? best;
|
||||
for (final entry in accounts) {
|
||||
if (entry.id == excludedId) continue;
|
||||
if (best == null || entry.lastUsed > best.lastUsed) best = entry;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
String encode() => jsonEncode({
|
||||
'activeId': activeId,
|
||||
'accounts': [for (final e in accounts) e.toJson()],
|
||||
});
|
||||
|
||||
static AccountIndex decode(String? raw) {
|
||||
if (raw == null || raw.isEmpty) return empty;
|
||||
try {
|
||||
final json = jsonDecode(raw);
|
||||
if (json is! Map) return empty;
|
||||
final list = json['accounts'];
|
||||
return AccountIndex(
|
||||
accounts: [
|
||||
if (list is List)
|
||||
for (final item in list) ?AccountEntry.fromJson(item),
|
||||
],
|
||||
activeId: json['activeId'] is String
|
||||
? json['activeId'] as String
|
||||
: null,
|
||||
);
|
||||
} on FormatException {
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(String kind, String label) identityOf(Session session) => switch (session) {
|
||||
CredentialSession(:final username) => (SessionKeys.kindCredential, username),
|
||||
GuardianSession(:final email) => (SessionKeys.kindGuardian, email),
|
||||
};
|
||||
|
||||
/// Keychain fields of [session] including the app passwords, as stored in an
|
||||
/// inactive account's vault. Unset fields are omitted.
|
||||
Map<String, String> sessionVaultFields(Session session) => {
|
||||
for (final MapEntry(:key, :value) in encodeSessionFields(session).entries)
|
||||
key: ?value,
|
||||
SessionKeys.appPassword: ?session.nextcloud?.appPassword,
|
||||
SessionKeys.appPasswordTalk: ?session.nextcloud?.appPasswordTalk,
|
||||
};
|
||||
|
||||
String encodeVault(Map<String, String> fields) => jsonEncode(fields);
|
||||
|
||||
Map<String, String> decodeVault(String? raw) {
|
||||
if (raw == null || raw.isEmpty) return const {};
|
||||
try {
|
||||
final json = jsonDecode(raw);
|
||||
if (json is! Map) return const {};
|
||||
return {
|
||||
for (final MapEntry(:key, :value) in json.entries)
|
||||
if (key is String && value is String) key: value,
|
||||
};
|
||||
} on FormatException {
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,38 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../api/marianumcloud/app_password/delete_app_password.dart';
|
||||
import '../api/marianumconnect/auth/auth_interceptor.dart';
|
||||
import '../api/marianumconnect/auth/token_storage.dart';
|
||||
import '../api/marianumconnect/queries/auth_logout/auth_logout.dart';
|
||||
import '../api/marianumconnect/queries/auth_me/auth_me.dart';
|
||||
import '../auth_link/guardian_link_listener.dart';
|
||||
import '../background/widget_background_task.dart';
|
||||
import '../push/chat_thread_store.dart';
|
||||
import '../push/nid_store.dart';
|
||||
import '../push/push_registration.dart';
|
||||
import '../storage/account_storage.dart';
|
||||
import '../widget_data/widget_sync.dart';
|
||||
import 'session.dart';
|
||||
import 'session_manager.dart';
|
||||
|
||||
abstract final class SessionLifecycle {
|
||||
/// Why the last sign-out happened, when the user did not ask for it. The
|
||||
/// login screen shows it once and clears it — without it an expired session
|
||||
/// just drops the user on the login screen with no explanation.
|
||||
/// login screen (or, when another account takes over, the app) shows it
|
||||
/// once and clears it.
|
||||
static final ValueNotifier<String?> signOutNotice = ValueNotifier(null);
|
||||
|
||||
/// Account to return to while "add account" runs; null otherwise.
|
||||
static String? _addReturnId;
|
||||
|
||||
/// Ordered teardown: unregister push and revoke the Nextcloud app passwords
|
||||
/// (while those credentials still exist), then revoke the MC bearer token,
|
||||
/// finally wipe the local session. Each step is best-effort so an offline
|
||||
/// sign-out still reaches a clean local state.
|
||||
static Future<void> signOut({String? notice}) async {
|
||||
/// finally wipe the local session and its data. Each step is best-effort so
|
||||
/// an offline sign-out still reaches a clean local state. Another signed-in
|
||||
/// account takes over when there is one; returns its id.
|
||||
static Future<String?> signOut({String? notice}) async {
|
||||
signOutNotice.value = notice;
|
||||
try {
|
||||
await PushRegistration().logoutCleanup();
|
||||
@@ -25,9 +40,165 @@ abstract final class SessionLifecycle {
|
||||
log('Sign-out: push cleanup failed: $e');
|
||||
}
|
||||
await AuthLogout().run();
|
||||
await SessionManager().signOut();
|
||||
final removed = await SessionManager().signOut();
|
||||
// A login link that arrived while signed in must not be replayed on the
|
||||
// login screen that follows.
|
||||
GuardianLinkListener.clear();
|
||||
await _clearPushStores();
|
||||
if (removed != null) await AccountStorage.delete(removed.namespace);
|
||||
|
||||
for (
|
||||
var next = SessionManager().accounts.value.mostRecentExcept(null);
|
||||
next != null;
|
||||
next = SessionManager().accounts.value.mostRecentExcept(null)
|
||||
) {
|
||||
try {
|
||||
await SessionManager().activate(next.id);
|
||||
break;
|
||||
} on Object catch (e) {
|
||||
log('Sign-out: taking over ${next.id} failed: $e');
|
||||
await SessionManager().forget(next.id);
|
||||
}
|
||||
}
|
||||
await AccountStorage.activate(SessionManager().activeAccount);
|
||||
if (SessionManager().isSignedIn) await _resetWidget();
|
||||
return SessionManager().activeAccount?.id;
|
||||
}
|
||||
|
||||
/// Makes the stored account [id] the active one. Push moves along: the
|
||||
/// previous account is unregistered here, the new one registers once the
|
||||
/// app has remounted for it.
|
||||
static Future<void> switchTo(String id) async {
|
||||
final previous = SessionManager().activeAccount;
|
||||
if (previous?.id == id) return;
|
||||
await MarianumConnectAuthInterceptor.idle();
|
||||
try {
|
||||
await PushRegistration().deactivate();
|
||||
} on Object catch (e) {
|
||||
log('Switch: push deactivation failed: $e');
|
||||
}
|
||||
await SessionManager().stashActive();
|
||||
try {
|
||||
await SessionManager().activate(id);
|
||||
} on Object {
|
||||
if (previous != null) await SessionManager().activate(previous.id);
|
||||
rethrow;
|
||||
} finally {
|
||||
await _clearPushStores();
|
||||
await AccountStorage.activate(SessionManager().activeAccount);
|
||||
}
|
||||
await _resetWidget();
|
||||
}
|
||||
|
||||
/// Parks the active account before the login screen signs in another one.
|
||||
static Future<void> beginAddAccount() async {
|
||||
await SessionManager().stashActive();
|
||||
_addReturnId = SessionManager().activeAccount?.id;
|
||||
}
|
||||
|
||||
static bool get isAddingAccount => _addReturnId != null;
|
||||
|
||||
/// Leaves "add account" without a new account: a half-finished sign-in is
|
||||
/// dropped and the previous account restored.
|
||||
static Future<void> cancelAddAccount() async {
|
||||
final returnId = _addReturnId;
|
||||
_addReturnId = null;
|
||||
if (returnId == null) return;
|
||||
if (SessionManager().activeAccount?.id != returnId &&
|
||||
SessionManager().isSignedIn) {
|
||||
await SessionManager().signOut();
|
||||
}
|
||||
await SessionManager().activate(returnId);
|
||||
}
|
||||
|
||||
/// Called once a login finished. After "add account" the previous account's
|
||||
/// push is unregistered with its own credentials before the new one takes
|
||||
/// over. Returns the id of the now active account.
|
||||
static Future<String> finishLogin() async {
|
||||
final returnId = _addReturnId;
|
||||
_addReturnId = null;
|
||||
final added = SessionManager().activeAccount!;
|
||||
if (returnId != null && returnId != added.id) {
|
||||
await SessionManager().stashActive();
|
||||
await SessionManager().activate(returnId);
|
||||
try {
|
||||
await PushRegistration().deactivate();
|
||||
} on Object catch (e) {
|
||||
log('Add account: push deactivation failed: $e');
|
||||
}
|
||||
await SessionManager().activate(added.id);
|
||||
await _clearPushStores();
|
||||
}
|
||||
await AccountStorage.activate(SessionManager().activeAccount);
|
||||
return added.id;
|
||||
}
|
||||
|
||||
/// Refreshes the stored real name of the active account (best-effort).
|
||||
static Future<void> refreshDisplayName() async {
|
||||
if (SessionManager().current case final session? when !session.isDemo) {
|
||||
try {
|
||||
final user = await AuthMe().user();
|
||||
final name = '${user.firstName} ${user.lastName}'.trim();
|
||||
if (name.isNotEmpty) await SessionManager().setDisplayName(name);
|
||||
} on Object catch (e) {
|
||||
log('Display name refresh failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Signs out an account that is not active: revokes its tokens with the
|
||||
/// stored credentials (best-effort) and deletes its local data. Its push
|
||||
/// was already unregistered when it stopped being active.
|
||||
static Future<void> removeInactive(String id) async {
|
||||
final entry = SessionManager().accounts.value.byId(id);
|
||||
if (entry == null || entry.id == SessionManager().activeAccount?.id) return;
|
||||
final (session, fields) = await SessionManager().readVault(id);
|
||||
if (session != null && !session.isDemo) {
|
||||
await _revoke(session, fields[MarianumConnectTokenStorage.bearerKey]);
|
||||
}
|
||||
await SessionManager().forget(id);
|
||||
await AccountStorage.delete(entry.namespace);
|
||||
}
|
||||
|
||||
static Future<void> _revoke(Session session, String? token) async {
|
||||
if (token != null && token.isNotEmpty) await AuthLogout.revoke(token);
|
||||
final nextcloud = session.nextcloud;
|
||||
if (nextcloud == null) return;
|
||||
try {
|
||||
if (nextcloud.hasAppPassword) {
|
||||
await DeleteAppPassword().run(
|
||||
authorizationHeader: nextcloud.basicAuthHeader,
|
||||
);
|
||||
}
|
||||
if (nextcloud.hasAppPasswordTalk) {
|
||||
await DeleteAppPassword().run(
|
||||
authorizationHeader: nextcloud.talkBasicAuthHeader,
|
||||
);
|
||||
}
|
||||
} on Object catch (e) {
|
||||
log('Remove account: app password revoke failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// The widget still shows the previous account's plan; the app republishes
|
||||
/// once it mounted, the refresh covers a backgrounded app.
|
||||
static Future<void> _resetWidget() async {
|
||||
try {
|
||||
await WidgetSync.clear();
|
||||
await WidgetSync.triggerUpdate();
|
||||
unawaited(WidgetBackgroundTask.requestImmediateRefresh());
|
||||
} on Object catch (e) {
|
||||
log('Widget reset failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Tray bookkeeping of the previous account's pushes.
|
||||
static Future<void> _clearPushStores() async {
|
||||
try {
|
||||
await NidStore().clear();
|
||||
await ChatThreadStore().clearAll();
|
||||
} on Object catch (e) {
|
||||
log('Push store cleanup failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,22 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../api/marianumconnect/auth/token_storage.dart';
|
||||
import '../push/push_secure_storage.dart';
|
||||
import '../utils/exponential_backoff.dart';
|
||||
import '../utils/random_id.dart';
|
||||
import 'account_codec.dart';
|
||||
import 'nextcloud_credentials.dart';
|
||||
import 'session.dart';
|
||||
import 'session_codec.dart';
|
||||
|
||||
/// Owns the active [Session] and its persistence. One instance per isolate;
|
||||
/// the widget background isolate reads the same keychain.
|
||||
///
|
||||
/// Several accounts can be signed in; the frozen [SessionKeys] slots, the MC
|
||||
/// token and the group-keychain app passwords always hold the *active* one
|
||||
/// (native code and the background isolate read only those). Inactive
|
||||
/// accounts are parked in a per-account vault entry.
|
||||
class SessionManager {
|
||||
// `first_unlock` so a background launch on a locked device (silent push,
|
||||
// BGAppRefresh) can still read the session. Items written by older versions
|
||||
@@ -34,6 +42,10 @@ class SessionManager {
|
||||
SessionKeys.loginFlow,
|
||||
];
|
||||
|
||||
static const _indexKey = 'accounts_index';
|
||||
static String _vaultKey(String id) => 'account_vault_$id';
|
||||
static const _tokenStorage = MarianumConnectTokenStorage();
|
||||
|
||||
static final SessionManager _instance = SessionManager._();
|
||||
factory SessionManager() => _instance;
|
||||
|
||||
@@ -46,6 +58,13 @@ class SessionManager {
|
||||
|
||||
Session? get current => _current;
|
||||
|
||||
/// Every signed-in account and which one is active.
|
||||
final ValueNotifier<AccountIndex> accounts = ValueNotifier(
|
||||
AccountIndex.empty,
|
||||
);
|
||||
|
||||
AccountEntry? get activeAccount => accounts.value.active;
|
||||
|
||||
bool get isSignedIn => _current != null;
|
||||
|
||||
bool get isDemo => _current?.isDemo ?? false;
|
||||
@@ -80,8 +99,22 @@ class SessionManager {
|
||||
NextcloudCredentials requireNextcloud() =>
|
||||
_current?.nextcloud ?? (throw const NextcloudUnavailableException());
|
||||
|
||||
/// Replaces any stored session completely; no prior [signOut] needed.
|
||||
/// Makes [session] the active account, replacing the active slots
|
||||
/// completely. An account signed in before keeps its entry; call
|
||||
/// [stashActive] first so the previously active one stays switchable.
|
||||
Future<void> signIn(Session session) async {
|
||||
await _writeActive(session);
|
||||
await _saveIndex(
|
||||
accounts.value.activate(
|
||||
session,
|
||||
newId: randomHexId(bytes: 8),
|
||||
now: _now(),
|
||||
),
|
||||
);
|
||||
if (!_loaded.isCompleted) _loaded.complete();
|
||||
}
|
||||
|
||||
Future<void> _writeActive(Session session) async {
|
||||
await Future.wait([
|
||||
for (final MapEntry(:key, :value) in encodeSessionFields(session).entries)
|
||||
_writeSecret(key, value),
|
||||
@@ -95,19 +128,78 @@ class SessionManager {
|
||||
),
|
||||
]);
|
||||
_current = session;
|
||||
if (!_loaded.isCompleted) _loaded.complete();
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
/// Wipes the active slots and forgets the active account. Other accounts
|
||||
/// stay in their vaults; see [activate].
|
||||
Future<AccountEntry?> signOut() async {
|
||||
final removed = activeAccount;
|
||||
_loaded = Completer();
|
||||
_current = null;
|
||||
await Future.wait([
|
||||
for (final field in _sessionFields) _secureStorage.delete(key: field),
|
||||
_writeGroupSecret(SessionKeys.appPassword, null),
|
||||
_writeGroupSecret(SessionKeys.appPasswordTalk, null),
|
||||
if (removed != null) _secureStorage.delete(key: _vaultKey(removed.id)),
|
||||
]);
|
||||
if (removed != null) await _saveIndex(accounts.value.remove(removed.id));
|
||||
return removed;
|
||||
}
|
||||
|
||||
/// Parks the active account (session, app passwords, MC token) in its vault
|
||||
/// so the slots can be taken over by another account.
|
||||
Future<void> stashActive() async {
|
||||
final session = _current;
|
||||
final entry = activeAccount;
|
||||
if (session == null || entry == null) return;
|
||||
final fields = {
|
||||
...sessionVaultFields(session),
|
||||
...await _tokenStorage.readAll(),
|
||||
};
|
||||
await _secureStorage.write(
|
||||
key: _vaultKey(entry.id),
|
||||
value: encodeVault(fields),
|
||||
);
|
||||
}
|
||||
|
||||
/// Loads the vault of [id] into the active slots. The active account must
|
||||
/// have been stashed before, or its session is lost.
|
||||
Future<void> activate(String id) async {
|
||||
final fields = decodeVault(await _secureStorage.read(key: _vaultKey(id)));
|
||||
final session = decodeSession(fields);
|
||||
if (session == null) throw StateError('No stored session for account $id');
|
||||
await _writeActive(session);
|
||||
await _tokenStorage.writeAll(fields);
|
||||
await _saveIndex(accounts.value.select(id, now: _now()));
|
||||
if (!_loaded.isCompleted) _loaded.complete();
|
||||
}
|
||||
|
||||
/// Remembers the real name of the active account for the account list.
|
||||
Future<void> setDisplayName(String displayName) async {
|
||||
final entry = activeAccount;
|
||||
if (entry == null || entry.displayName == displayName) return;
|
||||
await _saveIndex(accounts.value.rename(entry.id, displayName));
|
||||
}
|
||||
|
||||
/// Session and MC token of an inactive account, e.g. to revoke them.
|
||||
Future<(Session?, Map<String, String>)> readVault(String id) async {
|
||||
final fields = decodeVault(await _secureStorage.read(key: _vaultKey(id)));
|
||||
return (decodeSession(fields), fields);
|
||||
}
|
||||
|
||||
/// Drops an inactive account without touching the active slots.
|
||||
Future<void> forget(String id) async {
|
||||
await _secureStorage.delete(key: _vaultKey(id));
|
||||
await _saveIndex(accounts.value.remove(id));
|
||||
}
|
||||
|
||||
Future<void> _saveIndex(AccountIndex index) async {
|
||||
accounts.value = index;
|
||||
await _secureStorage.write(key: _indexKey, value: index.encode());
|
||||
}
|
||||
|
||||
static int _now() => DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
/// Persists a freshly minted Nextcloud app password; from then on every
|
||||
/// Nextcloud call authenticates with it instead of the real password.
|
||||
Future<void> setAppPassword(String appPassword) async {
|
||||
@@ -205,9 +297,36 @@ class SessionManager {
|
||||
// Group keystore unavailable: fall back to the real password.
|
||||
}
|
||||
_current = decodeSession(raw);
|
||||
await _loadIndex();
|
||||
if (!_loaded.isCompleted) _loaded.complete();
|
||||
}
|
||||
|
||||
Future<void> _loadIndex() async {
|
||||
var index = AccountIndex.decode(await _secureStorage.read(key: _indexKey));
|
||||
final session = _current;
|
||||
if (session != null) {
|
||||
if (index.active == null ||
|
||||
index.matching(session)?.id != index.activeId) {
|
||||
// First start after the update: the existing account keeps its data
|
||||
// in the un-namespaced storage.
|
||||
index = index.activate(
|
||||
session,
|
||||
newId: randomHexId(bytes: 8),
|
||||
namespace: index.accounts.isEmpty ? '' : null,
|
||||
now: _now(),
|
||||
);
|
||||
await _secureStorage.write(key: _indexKey, value: index.encode());
|
||||
}
|
||||
accounts.value = index;
|
||||
return;
|
||||
}
|
||||
accounts.value = index.remove(index.activeId ?? '');
|
||||
// Interrupted switch or sign-out: fall back to another stored account
|
||||
// instead of showing the login screen.
|
||||
final fallback = accounts.value.mostRecentExcept(null);
|
||||
if (fallback != null) await activate(fallback.id);
|
||||
}
|
||||
|
||||
// Move credentials from the old SharedPreferences plain-text storage into the
|
||||
// platform's secure keystore. Run once per install and clear the legacy keys.
|
||||
Future<void> _migrateFromLegacyStorage() async {
|
||||
|
||||
Reference in New Issue
Block a user