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 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 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 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 fields) => jsonEncode(fields); Map 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 {}; } }