added support for multiple accounts, guardian login bugfixes, ui changes

This commit is contained in:
2026-09-23 20:50:12 +02:00
parent 630497abdd
commit 84098af7e2
37 changed files with 1759 additions and 376 deletions
+221
View File
@@ -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 {};
}
}