138 lines
4.7 KiB
Dart
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);
|
|
}
|
|
}
|