222 lines
6.2 KiB
Dart
222 lines
6.2 KiB
Dart
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 {};
|
|
}
|
|
}
|