mobile-music/lib/subsonic/credentials.dart
2026-07-29 22:41:38 -04:00

138 lines
4.7 KiB
Dart

import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// A Subsonic server connection. The password is only ever held in memory and
/// in the platform secure store — never in plain app storage (mirrors Timbre's
/// keyring-first secret handling).
class SubsonicCredentials {
const SubsonicCredentials({
required this.url,
required this.username,
required this.password,
this.alias,
});
final String url;
final String username;
final String password;
/// Optional display label shown instead of the raw URL (Timbre `[server].alias`).
final String? alias;
String get display => alias?.isNotEmpty == true ? alias! : Uri.parse(url).host;
/// Stable identity for a saved server: `md5(baseUrl|username)`, matching the
/// cache key in `serverKeyProvider` so per-server data lines up. Two entries
/// with the same URL + username are the "same" server (different password /
/// alias overwrites it).
String get id {
final base = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
return md5.convert(utf8.encode('$base|$username')).toString();
}
Map<String, dynamic> toJson() => {
'url': url,
'username': username,
'password': password,
if (alias != null && alias!.isNotEmpty) 'alias': alias,
};
factory SubsonicCredentials.fromJson(Map<String, dynamic> j) {
final alias = j['alias'] as String?;
return SubsonicCredentials(
url: j['url'] as String? ?? '',
username: j['username'] as String? ?? '',
password: j['password'] as String? ?? '',
alias: (alias == null || alias.isEmpty) ? null : alias,
);
}
}
/// Persists a *list* of [SubsonicCredentials] plus a pointer to the active one
/// to the platform secure store (Keychain / Keystore / libsecret). Legacy
/// single-server installs are migrated into the list on first load.
class CredentialStore {
CredentialStore([FlutterSecureStorage? storage])
: _storage = storage ?? const FlutterSecureStorage();
final FlutterSecureStorage _storage;
/// JSON array of all saved servers.
static const _kServers = 'subsonic_servers';
/// Id ([SubsonicCredentials.id]) of the active server.
static const _kActive = 'subsonic_active';
// Legacy single-server keys, read once for migration then removed.
static const _kUrl = 'subsonic_url';
static const _kUser = 'subsonic_username';
static const _kPass = 'subsonic_password';
static const _kAlias = 'subsonic_alias';
/// All saved servers. Empty if none configured. Migrates a legacy
/// single-server install into the new list format on first call.
Future<List<SubsonicCredentials>> loadAll() async {
final raw = await _storage.read(key: _kServers);
if (raw != null && raw.isNotEmpty) {
try {
final list = (jsonDecode(raw) as List)
.whereType<Map>()
.map((m) => SubsonicCredentials.fromJson(m.cast<String, dynamic>()))
.where((c) => c.url.isNotEmpty && c.username.isNotEmpty)
.toList();
return list;
} catch (_) {
return const [];
}
}
// Migrate a legacy single-server install, if present.
final migrated = await _migrateLegacy();
return migrated == null ? const [] : [migrated];
}
/// Id of the active server, or null.
Future<String?> loadActiveId() => _storage.read(key: _kActive);
/// Overwrite the saved list and active pointer atomically-ish (two writes).
Future<void> saveAll(
List<SubsonicCredentials> servers, String? activeId) async {
await _storage.write(
key: _kServers,
value: jsonEncode([for (final s in servers) s.toJson()]),
);
if (activeId == null) {
await _storage.delete(key: _kActive);
} else {
await _storage.write(key: _kActive, value: activeId);
}
}
Future<SubsonicCredentials?> _migrateLegacy() async {
final url = await _storage.read(key: _kUrl);
final user = await _storage.read(key: _kUser);
final pass = await _storage.read(key: _kPass);
if (url == null || user == null || pass == null) return null;
final alias = await _storage.read(key: _kAlias);
final creds = SubsonicCredentials(
url: url,
username: user,
password: pass,
alias: (alias == null || alias.isEmpty) ? null : alias,
);
// Write the new format, point at it, then drop the legacy keys.
await saveAll([creds], creds.id);
await _storage.delete(key: _kUrl);
await _storage.delete(key: _kUser);
await _storage.delete(key: _kPass);
await _storage.delete(key: _kAlias);
return creds;
}
/// Remove all saved servers and the active pointer.
Future<void> clear() async {
await _storage.delete(key: _kServers);
await _storage.delete(key: _kActive);
}
}