feat(train): the Jellyfin token in Train's own store, sealed by the Keystore (ADR 0030, D52)

The token moves from flutter_secure_storage into JellyfinTokens.kt: a
file per person in the no-backup folder, sealed with AES-GCM under a key
of Android's Keystore, the person's ID as associated data, so that the
music job to come can read it as the app does. A token the keyring held
is moved over as it is first read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-29 18:42:46 +01:00
commit 6bde74f0ba
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 342 additions and 18 deletions

View file

@ -426,7 +426,7 @@ Future<TrainPerson> _openPersonData(
final music = Platform.isAndroid
? MusicLibrary(
MusicStore(File(p.join(folder.path, 'music.json'))),
SecureJellyfinTokenStore(person.authUserId),
AndroidJellyfinTokenStore(person.authUserId),
version: appVersion,
)
: null;

View file

@ -4,6 +4,7 @@ import 'dart:io';
import 'dart:math' show Random;
import 'package:equatable/equatable.dart';
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Music from Jellyfin on this phone, for one person (ADR 0030, D47): never
@ -137,32 +138,50 @@ abstract interface class JellyfinTokenStore() {
Future<void> forgetOthers();
}
/// [JellyfinTokenStore] in the system's keyring (Android's Keystore), under
/// `train_jellyfin:` and the person's ID.
class SecureJellyfinTokenStore(
/// [JellyfinTokenStore] in Train's own store on Android (ADR 0030, D52),
/// over `app.halkyon.train/music` (`JellyfinTokens.kt`: sealed under a key
/// of the Keystore's, where the music job reads it too). A token the system's
/// keyring kept before, under `train_jellyfin:` and the person's ID, is moved
/// over as it is first read, and leaves the keyring.
class AndroidJellyfinTokenStore(
final String _person, {
final FlutterSecureStorage _storage = const FlutterSecureStorage(),
final MethodChannel _channel = const MethodChannel('app.halkyon.train/music'),
final FlutterSecureStorage _keyring = const FlutterSecureStorage(),
}) implements JellyfinTokenStore {
static const _prefix = 'train_jellyfin:';
static const _keyringPrefix = 'train_jellyfin:';
String get _key => '$_prefix$_person';
String get _keyringKey => '$_keyringPrefix$_person';
@override
Future<String?> read() => _storage.read(key: _key);
Future<String?> read() async {
final token = await _channel.invokeMethod<String>('tokenRead', {
'person': _person,
});
if (token != null) {
return token;
}
final before = await _keyring.read(key: _keyringKey);
if (before != null) {
await keep(before);
}
return before;
}
@override
Future<void> keep(String? token) async {
await _storage.delete(key: _key);
if (token != null) {
await _storage.write(key: _key, value: token);
}
await _channel.invokeMethod<void>('tokenKeep', {
'person': _person,
'token': token,
});
await _keyring.delete(key: _keyringKey);
}
@override
Future<void> forgetOthers() async {
for (final key in (await _storage.readAll()).keys) {
if (key.startsWith(_prefix) && key != _key) {
await _storage.delete(key: key);
await _channel.invokeMethod<void>('tokenForgetOthers', {'person': _person});
for (final key in [...(await _keyring.readAll()).keys]) {
if (key.startsWith(_keyringPrefix) && key != _keyringKey) {
await _keyring.delete(key: key);
}
}
}