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 toJson() => { 'url': url, 'username': username, 'password': password, if (alias != null && alias!.isNotEmpty) 'alias': alias, }; factory SubsonicCredentials.fromJson(Map 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> loadAll() async { final raw = await _storage.read(key: _kServers); if (raw != null && raw.isNotEmpty) { try { final list = (jsonDecode(raw) as List) .whereType() .map((m) => SubsonicCredentials.fromJson(m.cast())) .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 loadActiveId() => _storage.read(key: _kActive); /// Overwrite the saved list and active pointer atomically-ish (two writes). Future saveAll( List 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 _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 clear() async { await _storage.delete(key: _kServers); await _storage.delete(key: _kActive); } }