added support for multiple accounts with an account switcher in settings
This commit is contained in:
@@ -2,17 +2,26 @@ import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
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
|
||||
@@ -32,6 +41,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,7 +59,8 @@ class SessionManager {
|
||||
|
||||
int _sessionEpoch = 0;
|
||||
|
||||
/// Bumped whenever the signed-in account changes. Async work captures it when it starts and drops its
|
||||
/// Bumped whenever the active account changes (sign-out, switch, another
|
||||
/// account signing in). Async work captures it when it starts and drops its
|
||||
/// result when it changed meanwhile, so a request of the previous account
|
||||
/// cannot land in the next account's state or cache.
|
||||
int get sessionEpoch => _sessionEpoch;
|
||||
@@ -58,6 +72,13 @@ class SessionManager {
|
||||
/// Called from `main()`; background entry points never run it.
|
||||
void markUiEngine() => _isUiEngine = true;
|
||||
|
||||
/// 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;
|
||||
@@ -85,8 +106,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 {
|
||||
if (!_isSameAccount(_current, session)) _sessionEpoch++;
|
||||
await Future.wait([
|
||||
for (final MapEntry(:key, :value) in encodeSessionFields(session).entries)
|
||||
@@ -101,10 +136,12 @@ 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;
|
||||
_sessionEpoch++;
|
||||
_loaded = Completer();
|
||||
_current = null;
|
||||
@@ -112,14 +149,70 @@ class SessionManager {
|
||||
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;
|
||||
}
|
||||
|
||||
static bool _isSameAccount(Session? a, Session? b) => switch ((a, b)) {
|
||||
(final CredentialSession a, final CredentialSession b) =>
|
||||
a.username == b.username && a.isDemo == b.isDemo,
|
||||
_ => a == b,
|
||||
};
|
||||
/// 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));
|
||||
}
|
||||
|
||||
static bool _isSameAccount(Session? a, Session? b) {
|
||||
if (a == null || b == null) return a == b;
|
||||
return identityOf(a) == identityOf(b) && a.isDemo == b.isDemo;
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -202,6 +295,7 @@ class SessionManager {
|
||||
await _migrateFromLegacyStorage();
|
||||
await _migrateKeychainAccessibility();
|
||||
_current = await _readActive();
|
||||
await _loadIndex();
|
||||
if (!_loaded.isCompleted) _loaded.complete();
|
||||
}
|
||||
|
||||
@@ -226,28 +320,60 @@ class SessionManager {
|
||||
return decodeSession(raw);
|
||||
}
|
||||
|
||||
/// Username currently in the keystore. Other engines (widget task, push
|
||||
/// isolates) sign out or in without this instance noticing.
|
||||
/// Username currently in the active slots. Other engines (widget task, push
|
||||
/// isolates) sign out, in or switch without this instance noticing.
|
||||
Future<String?> readStoredUsername() =>
|
||||
_secureStorage.read(key: SessionKeys.username);
|
||||
|
||||
/// Re-reads the session for long-lived background engines: they load once
|
||||
/// and would otherwise keep acting with an account that signed out in the
|
||||
/// app meanwhile. Keeps the known state when the keystore is unreadable.
|
||||
/// and would otherwise keep acting with an account that signed out or was
|
||||
/// switched away from in the app meanwhile. Keeps the known state when the
|
||||
/// keystore is unreadable.
|
||||
Future<void> reloadFromStorage() async {
|
||||
// The UI engine performs sign-in and sign-out itself, so its state is
|
||||
// current; re-reading mid sign-out could resurrect the removed account.
|
||||
// The UI engine performs sign-in, sign-out and switches itself, so its
|
||||
// state is current; re-reading in between could resurrect the removed
|
||||
// account.
|
||||
if (_isUiEngine) return;
|
||||
try {
|
||||
final session = await _readActive();
|
||||
final index = AccountIndex.decode(
|
||||
await _secureStorage.read(key: _indexKey),
|
||||
);
|
||||
if (!_isSameAccount(_current, session)) _sessionEpoch++;
|
||||
_current = session;
|
||||
accounts.value = index;
|
||||
if (!_loaded.isCompleted) _loaded.complete();
|
||||
} on Object catch (e) {
|
||||
log('Session reload failed, keeping loaded state: $e');
|
||||
}
|
||||
}
|
||||
|
||||
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