feat(train): signing in to Jellyfin and marking playlists (ADR 0030, D47)

Music's decisions as D47, and its first part: the music screen from the
account sheet, on Android, signs in to the person's Jellyfin with Quick
Connect, else a password, and lists their playlists of music to mark
for running. The token is in the keyring, the server and the marks in
the person's music.json, nothing on Train's server.

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 12:36:32 +01:00
commit 3336fe4eca
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
18 changed files with 2364 additions and 3 deletions

File diff suppressed because one or more lines are too long

View file

@ -468,5 +468,31 @@
"recordingMapHint": "Wischen für die Karte",
"recordingFiguresHint": "Zurückwischen für die Werte",
"recordingNorthUp": "Norden oben",
"recordingHeadingUp": "Bewegungsrichtung oben"
"recordingHeadingUp": "Bewegungsrichtung oben",
"musicTitle": "Musik",
"musicHint": "Jellyfin-Playlists zum Laufen",
"musicIntro": "Train spielt Musik von deinem Jellyfin-Server und wählt Titel, deren Takt zu deinen Schritten passt. Dein Konto und deine Musik bleiben auf diesem Telefon.",
"musicServer": "Jellyfin-Server",
"musicConnect": "Verbinden",
"musicBadAddress": "Gib die Adresse des Servers ein, etwa jellyfin.example.org.",
"musicUnreachable": "Der Server ist nicht erreichbar. Prüfe die Adresse und die Verbindung.",
"musicNotJellyfin": "Unter dieser Adresse ist kein Jellyfin-Server.",
"musicRefused": "Jellyfin hat abgelehnt. Prüfe Benutzername und Passwort.",
"musicCodeExpired": "Der Code ist abgelaufen.",
"musicFailed": "Jellyfin hat mit einem Fehler geantwortet. Versuch es später noch einmal.",
"musicQuickConnect": "Um dich bei {server} anzumelden, gib diesen Code in Jellyfin auf einem anderen Gerät ein, unter Einstellungen, Quick Connect.",
"musicNewCode": "Neuer Code",
"musicBack": "Zurück",
"musicUsePassword": "Mit Passwort",
"musicPasswordIntro": "Melde dich mit deinem Jellyfin-Benutzernamen und Passwort bei {server} an.",
"musicUserName": "Benutzername",
"musicPassword": "Passwort",
"musicSignIn": "Anmelden",
"musicSignedInAs": "Angemeldet als {name}",
"musicSignOut": "Abmelden",
"musicPlaylists": "Playlists zum Laufen",
"musicPlaylistsHint": "Train wählt aus den Titeln der Playlists, die du markierst, und behält sie auf diesem Telefon.",
"musicNoPlaylists": "Auf diesem Server gibt es noch keine Musik-Playlists.",
"musicRetry": "Erneut versuchen",
"musicTracks": "{count, plural, =1{1 Titel} other{{count} Titel}}"
}

View file

@ -588,5 +588,38 @@
"recordingMapHint": "Swipe for the map",
"recordingFiguresHint": "Swipe back for the figures",
"recordingNorthUp": "North up",
"recordingHeadingUp": "Heading up"
"recordingHeadingUp": "Heading up",
"musicTitle": "Music",
"@musicTitle": {"description": "The music screen's title, and its tile in the account sheet."},
"musicHint": "Jellyfin playlists to run to",
"@musicHint": {"description": "Under Music in the account sheet."},
"musicIntro": "Train plays music from your Jellyfin server, choosing tracks whose beat fits your steps. Your account and music stay on this phone.",
"musicServer": "Jellyfin server",
"@musicServer": {"description": "The label of the server's address."},
"musicConnect": "Connect",
"musicBadAddress": "Enter the server's address, such as jellyfin.example.org.",
"musicUnreachable": "The server can't be reached. Check the address and the connection.",
"musicNotJellyfin": "There is no Jellyfin server at this address.",
"musicRefused": "Jellyfin refused. Check the user name and password.",
"musicCodeExpired": "The code has expired.",
"musicFailed": "Jellyfin answered with an error. Try again later.",
"musicQuickConnect": "To sign in to {server}, enter this code in Jellyfin on another device, under Settings, Quick Connect.",
"@musicQuickConnect": {"placeholders": {"server": {"type": "String"}}},
"musicNewCode": "New code",
"musicBack": "Back",
"musicUsePassword": "Use a password",
"musicPasswordIntro": "Sign in to {server} with your Jellyfin user name and password.",
"@musicPasswordIntro": {"placeholders": {"server": {"type": "String"}}},
"musicUserName": "User name",
"musicPassword": "Password",
"musicSignIn": "Sign in",
"musicSignedInAs": "Signed in as {name}",
"@musicSignedInAs": {"placeholders": {"name": {"type": "String"}}},
"musicSignOut": "Sign out",
"musicPlaylists": "Playlists for running",
"musicPlaylistsHint": "Train chooses from the tracks of the playlists you mark, and keeps them on this phone.",
"musicNoPlaylists": "There are no music playlists on this server yet.",
"musicRetry": "Try again",
"musicTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
"@musicTracks": {"placeholders": {"count": {"type": "int"}}}
}

View file

@ -2835,6 +2835,162 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Heading up'**
String get recordingHeadingUp;
/// The music screen's title, and its tile in the account sheet.
///
/// In en, this message translates to:
/// **'Music'**
String get musicTitle;
/// Under Music in the account sheet.
///
/// In en, this message translates to:
/// **'Jellyfin playlists to run to'**
String get musicHint;
/// No description provided for @musicIntro.
///
/// In en, this message translates to:
/// **'Train plays music from your Jellyfin server, choosing tracks whose beat fits your steps. Your account and music stay on this phone.'**
String get musicIntro;
/// The label of the server's address.
///
/// In en, this message translates to:
/// **'Jellyfin server'**
String get musicServer;
/// No description provided for @musicConnect.
///
/// In en, this message translates to:
/// **'Connect'**
String get musicConnect;
/// No description provided for @musicBadAddress.
///
/// In en, this message translates to:
/// **'Enter the server\'s address, such as jellyfin.example.org.'**
String get musicBadAddress;
/// No description provided for @musicUnreachable.
///
/// In en, this message translates to:
/// **'The server can\'t be reached. Check the address and the connection.'**
String get musicUnreachable;
/// No description provided for @musicNotJellyfin.
///
/// In en, this message translates to:
/// **'There is no Jellyfin server at this address.'**
String get musicNotJellyfin;
/// No description provided for @musicRefused.
///
/// In en, this message translates to:
/// **'Jellyfin refused. Check the user name and password.'**
String get musicRefused;
/// No description provided for @musicCodeExpired.
///
/// In en, this message translates to:
/// **'The code has expired.'**
String get musicCodeExpired;
/// No description provided for @musicFailed.
///
/// In en, this message translates to:
/// **'Jellyfin answered with an error. Try again later.'**
String get musicFailed;
/// No description provided for @musicQuickConnect.
///
/// In en, this message translates to:
/// **'To sign in to {server}, enter this code in Jellyfin on another device, under Settings, Quick Connect.'**
String musicQuickConnect(String server);
/// No description provided for @musicNewCode.
///
/// In en, this message translates to:
/// **'New code'**
String get musicNewCode;
/// No description provided for @musicBack.
///
/// In en, this message translates to:
/// **'Back'**
String get musicBack;
/// No description provided for @musicUsePassword.
///
/// In en, this message translates to:
/// **'Use a password'**
String get musicUsePassword;
/// No description provided for @musicPasswordIntro.
///
/// In en, this message translates to:
/// **'Sign in to {server} with your Jellyfin user name and password.'**
String musicPasswordIntro(String server);
/// No description provided for @musicUserName.
///
/// In en, this message translates to:
/// **'User name'**
String get musicUserName;
/// No description provided for @musicPassword.
///
/// In en, this message translates to:
/// **'Password'**
String get musicPassword;
/// No description provided for @musicSignIn.
///
/// In en, this message translates to:
/// **'Sign in'**
String get musicSignIn;
/// No description provided for @musicSignedInAs.
///
/// In en, this message translates to:
/// **'Signed in as {name}'**
String musicSignedInAs(String name);
/// No description provided for @musicSignOut.
///
/// In en, this message translates to:
/// **'Sign out'**
String get musicSignOut;
/// No description provided for @musicPlaylists.
///
/// In en, this message translates to:
/// **'Playlists for running'**
String get musicPlaylists;
/// No description provided for @musicPlaylistsHint.
///
/// In en, this message translates to:
/// **'Train chooses from the tracks of the playlists you mark, and keeps them on this phone.'**
String get musicPlaylistsHint;
/// No description provided for @musicNoPlaylists.
///
/// In en, this message translates to:
/// **'There are no music playlists on this server yet.'**
String get musicNoPlaylists;
/// No description provided for @musicRetry.
///
/// In en, this message translates to:
/// **'Try again'**
String get musicRetry;
/// No description provided for @musicTracks.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 track} other{{count} tracks}}'**
String musicTracks(int count);
}
class _AppLocalizationsDelegate

View file

@ -1681,4 +1681,104 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get recordingHeadingUp => 'Bewegungsrichtung oben';
@override
String get musicTitle => 'Musik';
@override
String get musicHint => 'Jellyfin-Playlists zum Laufen';
@override
String get musicIntro =>
'Train spielt Musik von deinem Jellyfin-Server und wählt Titel, deren Takt zu deinen Schritten passt. Dein Konto und deine Musik bleiben auf diesem Telefon.';
@override
String get musicServer => 'Jellyfin-Server';
@override
String get musicConnect => 'Verbinden';
@override
String get musicBadAddress =>
'Gib die Adresse des Servers ein, etwa jellyfin.example.org.';
@override
String get musicUnreachable =>
'Der Server ist nicht erreichbar. Prüfe die Adresse und die Verbindung.';
@override
String get musicNotJellyfin =>
'Unter dieser Adresse ist kein Jellyfin-Server.';
@override
String get musicRefused =>
'Jellyfin hat abgelehnt. Prüfe Benutzername und Passwort.';
@override
String get musicCodeExpired => 'Der Code ist abgelaufen.';
@override
String get musicFailed =>
'Jellyfin hat mit einem Fehler geantwortet. Versuch es später noch einmal.';
@override
String musicQuickConnect(String server) {
return 'Um dich bei $server anzumelden, gib diesen Code in Jellyfin auf einem anderen Gerät ein, unter Einstellungen, Quick Connect.';
}
@override
String get musicNewCode => 'Neuer Code';
@override
String get musicBack => 'Zurück';
@override
String get musicUsePassword => 'Mit Passwort';
@override
String musicPasswordIntro(String server) {
return 'Melde dich mit deinem Jellyfin-Benutzernamen und Passwort bei $server an.';
}
@override
String get musicUserName => 'Benutzername';
@override
String get musicPassword => 'Passwort';
@override
String get musicSignIn => 'Anmelden';
@override
String musicSignedInAs(String name) {
return 'Angemeldet als $name';
}
@override
String get musicSignOut => 'Abmelden';
@override
String get musicPlaylists => 'Playlists zum Laufen';
@override
String get musicPlaylistsHint =>
'Train wählt aus den Titeln der Playlists, die du markierst, und behält sie auf diesem Telefon.';
@override
String get musicNoPlaylists =>
'Auf diesem Server gibt es noch keine Musik-Playlists.';
@override
String get musicRetry => 'Erneut versuchen';
@override
String musicTracks(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Titel',
one: '1 Titel',
);
return '$_temp0';
}
}

View file

@ -1671,6 +1671,104 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get recordingHeadingUp => 'Heading up';
@override
String get musicTitle => 'Music';
@override
String get musicHint => 'Jellyfin playlists to run to';
@override
String get musicIntro =>
'Train plays music from your Jellyfin server, choosing tracks whose beat fits your steps. Your account and music stay on this phone.';
@override
String get musicServer => 'Jellyfin server';
@override
String get musicConnect => 'Connect';
@override
String get musicBadAddress =>
'Enter the server\'s address, such as jellyfin.example.org.';
@override
String get musicUnreachable =>
'The server can\'t be reached. Check the address and the connection.';
@override
String get musicNotJellyfin => 'There is no Jellyfin server at this address.';
@override
String get musicRefused =>
'Jellyfin refused. Check the user name and password.';
@override
String get musicCodeExpired => 'The code has expired.';
@override
String get musicFailed => 'Jellyfin answered with an error. Try again later.';
@override
String musicQuickConnect(String server) {
return 'To sign in to $server, enter this code in Jellyfin on another device, under Settings, Quick Connect.';
}
@override
String get musicNewCode => 'New code';
@override
String get musicBack => 'Back';
@override
String get musicUsePassword => 'Use a password';
@override
String musicPasswordIntro(String server) {
return 'Sign in to $server with your Jellyfin user name and password.';
}
@override
String get musicUserName => 'User name';
@override
String get musicPassword => 'Password';
@override
String get musicSignIn => 'Sign in';
@override
String musicSignedInAs(String name) {
return 'Signed in as $name';
}
@override
String get musicSignOut => 'Sign out';
@override
String get musicPlaylists => 'Playlists for running';
@override
String get musicPlaylistsHint =>
'Train chooses from the tracks of the playlists you mark, and keeps them on this phone.';
@override
String get musicNoPlaylists =>
'There are no music playlists on this server yet.';
@override
String get musicRetry => 'Try again';
@override
String musicTracks(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -4,6 +4,7 @@ import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/app/train_frame.dart';
import 'package:halkyon_train_flutter/src/feature/health/widget/health_screen.dart';
import 'package:halkyon_train_flutter/src/feature/loop/widget/loop_screen.dart';
import 'package:halkyon_train_flutter/src/feature/music/widget/music_screen.dart';
import 'package:halkyon_train_flutter/src/feature/profile/widget/profile_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -70,6 +71,14 @@ class const _AccountSheet() extends StatelessWidget {
subtitle: Text(l10n.loopHint),
onTap: () => openLoopScreen(context),
),
// Music from Jellyfin on this phone (D47).
if (PersonScope.of<TrainPerson>(context).music != null)
ListTile(
leading: const Icon(LucideIcons.music),
title: Text(l10n.musicTitle),
subtitle: Text(l10n.musicHint),
onTap: () => openMusicScreen(context),
),
ListTile(
leading: const Icon(LucideIcons.info),
title: Text(l10n.aboutTitle),

View file

@ -20,6 +20,8 @@ import 'package:halkyon_train_flutter/src/feature/loop/data/polar_loop.dart';
import 'package:halkyon_train_flutter/src/feature/map/data/map_source_repository.dart';
import 'package:halkyon_train_flutter/src/feature/map/data/offline_maps.dart';
import 'package:halkyon_train_flutter/src/feature/map/widget/map_tiles.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_library.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_repository.dart';
import 'package:halkyon_train_flutter/src/feature/plan/data/sync_plan_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_repository.dart';
@ -104,6 +106,10 @@ class TrainPerson(
/// Their Polar Loop on this phone (D34); null where Train can't hold one
/// (iOS for now, the desktop).
final PolarLoop? loop,
/// Their music from Jellyfin on this phone (D47); null where none plays
/// (iOS for now, the desktop).
final MusicLibrary? music,
final Future<void> Function()? _onDispose,
}) implements Disposable {
/// Workouts deleted, to be undone for a moment (D24); dropped with the
@ -127,6 +133,7 @@ class TrainPerson(
await routeDeletions.dispose();
await offlineMaps?.dispose();
await health?.dispose();
music?.dispose();
await loop?.dispose();
await _onDispose?.call();
}
@ -289,6 +296,7 @@ class TrainDependencies({
person,
// One person's folder on the device at a time, as Learn keeps it.
await folders.openOnly(person.authUserId),
appVersion: device.appVersion,
accountKeys: () => accountKeys.read(person.authUserId),
onClosed: closed.complete,
);
@ -325,6 +333,7 @@ Future<TrainPerson> _openPersonData(
AppCompatibility compatibility,
SignedIn person,
Directory folder, {
required String appVersion,
required Future<UnlockedAccountKeys?> Function() accountKeys,
required void Function() onClosed,
}) async {
@ -421,6 +430,14 @@ Future<TrainPerson> _openPersonData(
: null,
health: health,
loop: loop,
// Jellyfin's account and the marked playlists, on this phone alone.
music: Platform.isAndroid
? MusicLibrary(
MusicStore(File(p.join(folder.path, 'music.json'))),
SecureJellyfinTokenStore(person.authUserId),
version: appVersion,
)
: null,
onDispose: () async {
try {
await workouts.dispose();
@ -451,6 +468,10 @@ class TrainDevice({
/// This build's number (after the `+` in the pubspec's version), which the
/// server knows apps by (`AppCompatibility`); null where it has none.
required final int? appBuild,
/// This build's version (before the `+`), which Jellyfin lists Train's
/// sign-ins by (D47).
final String appVersion = '',
}) {
/// Makes them, [builtIn] being the instance the build names.
static Future<TrainDevice> create({required Instance builtIn}) async {
@ -460,6 +481,7 @@ class TrainDevice({
support: support,
storage: InstanceStorage(support, builtIn: builtIn),
appBuild: int.tryParse(package.buildNumber),
appVersion: package.version,
);
}
}

View file

@ -0,0 +1,338 @@
import 'dart:async';
import 'package:clock/clock.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/jellyfin_client.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_library.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
/// Where signing in to Jellyfin stands (ADR 0030, D47).
enum MusicStep() {
/// Reading what the phone keeps.
opening,
/// Asking for the server's address.
address,
/// Asking the server what it is.
finding,
/// Showing a Quick Connect code until it is approved.
quickConnect,
/// Asking for the user name and password.
password,
/// Signing in.
signingIn,
/// Signed in: the playlists.
signedIn,
}
/// Why something on the music screen failed: Jellyfin's failures, and
/// Train's own.
enum MusicFailure() {
badAddress,
unreachable,
notJellyfin,
refused,
expired,
failed;
static MusicFailure of(JellyfinException error) => switch (error.failure) {
JellyfinFailure.unreachable => unreachable,
JellyfinFailure.notJellyfin => notJellyfin,
JellyfinFailure.refused => refused,
JellyfinFailure.quickConnectOff || JellyfinFailure.failed => failed,
};
}
/// What the music screen shows.
final class const MusicState({
required final MusicSettings settings,
final MusicStep step = MusicStep.opening,
/// The server being signed in to.
final JellyfinServer? server,
/// The Quick Connect code to approve.
final String? code,
/// What just failed.
final MusicFailure? failure,
/// The person's playlists of music; null until read.
final List<JellyfinPlaylist>? playlists,
}) with Equatable {
MusicState copyWith({
MusicSettings? settings,
MusicStep? step,
JellyfinServer? Function()? server,
String? Function()? code,
MusicFailure? Function()? failure,
List<JellyfinPlaylist>? Function()? playlists,
}) => MusicState(
settings: settings ?? this.settings,
step: step ?? this.step,
server: server == null ? this.server : server(),
code: code == null ? this.code : code(),
failure: failure == null ? this.failure : failure(),
playlists: playlists == null ? this.playlists : playlists(),
);
@override
List<Object?> get props => [settings, step, server, code, failure, playlists];
}
/// The music screen: signing in to Jellyfin and marking playlists.
class MusicCubit(
final MusicLibrary _library, {
/// How often a Quick Connect code is asked about.
final Duration poll = const Duration(seconds: 3),
/// How long a code is waited for: Jellyfin forgets requests after ten
/// minutes.
final Duration expiry = const Duration(minutes: 9),
}) extends Cubit<MusicState> {
this : super(MusicState(settings: _library.settings.value));
Timer? _polling;
String? _secret;
DateTime? _since;
bool _asking = false;
Future<void> open() async {
_library.settings.addListener(_settingsChanged);
await _library.open();
if (isClosed) {
return;
}
if (_library.settings.value.signedIn) {
emit(state.copyWith(step: MusicStep.signedIn));
await loadPlaylists();
} else {
emit(state.copyWith(step: MusicStep.address));
}
}
/// Finds the server at [typed], then signs in with Quick Connect where
/// it has it, else asks for the password.
Future<void> find(String typed) async {
final address = jellyfinAddress(typed);
if (address == null) {
emit(state.copyWith(failure: () => MusicFailure.badAddress));
return;
}
emit(state.copyWith(step: MusicStep.finding, failure: () => null));
try {
final server = await _library.find(address);
if (isClosed) {
return;
}
emit(state.copyWith(server: () => server));
if (server.quickConnect) {
await newCode();
} else {
emit(state.copyWith(step: MusicStep.password));
}
} on JellyfinException catch (error) {
if (!isClosed) {
emit(
state.copyWith(
step: MusicStep.address,
failure: () => MusicFailure.of(error),
),
);
}
}
}
/// Asks the server for a Quick Connect code and waits for its approval;
/// a server that refuses asks for the password.
Future<void> newCode() async {
final server = state.server!;
_stopPolling();
emit(
state.copyWith(
step: MusicStep.finding,
failure: () => null,
code: () => null,
),
);
try {
final code = await _library.jellyfin.initiateQuickConnect(
server.address,
);
if (isClosed) {
return;
}
_secret = code.secret;
_since = clock.now();
emit(state.copyWith(step: MusicStep.quickConnect, code: () => code.code));
_polling = Timer.periodic(poll, (_) => unawaited(_ask()));
} on JellyfinException catch (error) {
if (!isClosed) {
emit(
error.failure == JellyfinFailure.quickConnectOff
? state.copyWith(step: MusicStep.password)
: state.copyWith(
step: MusicStep.address,
failure: () => MusicFailure.of(error),
),
);
}
}
}
/// Signs in with a password instead of the code.
void usePassword() {
_stopPolling();
emit(state.copyWith(step: MusicStep.password, failure: () => null));
}
/// Back to the address.
void back() {
_stopPolling();
emit(
state.copyWith(
step: MusicStep.address,
server: () => null,
code: () => null,
failure: () => null,
),
);
}
Future<void> signIn(String name, String password) async {
emit(state.copyWith(step: MusicStep.signingIn, failure: () => null));
try {
await _library.signInWithPassword(state.server!, name, password);
await _signedIn();
} on JellyfinException catch (error) {
if (!isClosed) {
emit(
state.copyWith(
step: MusicStep.password,
failure: () => MusicFailure.of(error),
),
);
}
}
}
/// Reads the person's playlists again.
Future<void> loadPlaylists() async {
emit(state.copyWith(failure: () => null));
try {
final playlists = await _library.playlists();
if (!isClosed) {
emit(state.copyWith(playlists: () => playlists));
}
} on JellyfinException catch (error) {
if (isClosed) {
return;
}
emit(
_library.settings.value.signedIn
? state.copyWith(failure: () => MusicFailure.of(error))
: MusicState(
settings: _library.settings.value,
step: MusicStep.address,
failure: MusicFailure.of(error),
),
);
}
}
void mark(JellyfinPlaylist playlist, {required bool on}) =>
_library.mark(playlist, on: on);
Future<void> signOut() async {
await _library.signOut();
if (!isClosed) {
emit(
MusicState(settings: _library.settings.value, step: MusicStep.address),
);
}
}
Future<void> _ask() async {
final server = state.server;
final secret = _secret;
if (server == null || secret == null || _asking) {
return;
}
if (clock.now().difference(_since!) > expiry) {
_stopPolling();
emit(
state.copyWith(code: () => null, failure: () => MusicFailure.expired),
);
return;
}
try {
_asking = true;
final approved = await _library.jellyfin.quickConnectApproved(
server.address,
secret,
);
if (!approved || _secret != secret || isClosed) {
return;
}
_stopPolling();
emit(state.copyWith(step: MusicStep.signingIn));
await _library.signInWithQuickConnect(server, secret);
await _signedIn();
} on JellyfinException catch (error) {
// A request the server forgot can't be approved any more; a failure to
// reach it is asked again at the next tick.
if (!isClosed && error.failure != JellyfinFailure.unreachable) {
_stopPolling();
emit(
state.copyWith(
step: MusicStep.quickConnect,
code: () => null,
failure: () => MusicFailure.of(error),
),
);
}
} finally {
_asking = false;
}
}
Future<void> _signedIn() async {
if (isClosed) {
return;
}
emit(
state.copyWith(
step: MusicStep.signedIn,
code: () => null,
playlists: () => null,
),
);
await loadPlaylists();
}
void _settingsChanged() {
if (!isClosed) {
emit(state.copyWith(settings: _library.settings.value));
}
}
void _stopPolling() {
_polling?.cancel();
_polling = null;
_secret = null;
}
@override
Future<void> close() {
_stopPolling();
_library.settings.removeListener(_settingsChanged);
return super.close();
}
}

View file

@ -0,0 +1,303 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:equatable/equatable.dart';
import 'package:http/http.dart' as http;
/// Why Jellyfin couldn't be asked (ADR 0030, D47).
enum JellyfinFailure() {
/// Nothing answered at the address.
unreachable,
/// Something answered, but not Jellyfin.
notJellyfin,
/// Jellyfin refused: a wrong password, a token taken back.
refused,
/// The server has Quick Connect switched off.
quickConnectOff,
/// Jellyfin answered with an error of its own.
failed,
}
/// Jellyfin couldn't be asked, for [failure].
final class const JellyfinException(final JellyfinFailure failure)
implements Exception {
@override
String toString() => 'JellyfinException(${failure.name})';
}
/// A Jellyfin server as it describes itself.
final class const JellyfinServer({
required final Uri address,
required final String name,
required final String id,
/// Whether it signs in with Quick Connect.
final bool quickConnect = false,
}) with Equatable {
@override
List<Object?> get props => [address, name, id, quickConnect];
}
/// A Quick Connect request: the code the person approves, the secret
/// Train keeps.
typedef QuickConnectCode = ({String code, String secret});
/// What signing in gives: Train's own token, and whose it is.
typedef JellyfinSignIn = ({String token, String userId, String userName});
/// What asking as the person takes.
typedef JellyfinSession = ({String token, String userId});
/// A playlist of the person's on Jellyfin.
final class const JellyfinPlaylist({
required final String id,
required final String name,
/// How many items Jellyfin counts in it.
final int tracks = 0,
}) with Equatable {
@override
List<Object?> get props => [id, name, tracks];
}
/// [typed] as a Jellyfin server's address: https where it names no scheme,
/// its path kept (a server under `/jellyfin`), no trailing slash; null where
/// it names no host or another scheme than http and https.
Uri? jellyfinAddress(String typed) {
final trimmed = typed.trim();
final withScheme = trimmed.contains('://') ? trimmed : 'https://$trimmed';
final uri = Uri.tryParse(withScheme);
if (uri == null ||
uri.host.isEmpty ||
!(uri.scheme == 'https' || uri.scheme == 'http')) {
return null;
}
return Uri(
scheme: uri.scheme,
host: uri.host,
port: uri.hasPort ? uri.port : null,
path: uri.path.replaceAll(RegExp(r'/+$'), ''),
);
}
/// Jellyfin's API as Train asks it (D47), over plain HTTP: as the client
/// "Halkyon Train" on the device `deviceId`, to which Jellyfin ties the
/// tokens it gives.
class JellyfinClient(
final http.Client _http, {
required final String deviceId,
required final String version,
final String device = 'Android',
}) {
static const _timeout = Duration(seconds: 15);
/// What [address] is; [JellyfinFailure.notJellyfin] where it isn't
/// Jellyfin.
Future<JellyfinServer> server(Uri address) async {
final Object? info;
try {
info = await _send('GET', address, '/System/Info/Public');
} on JellyfinException catch (error) {
if (error.failure == JellyfinFailure.unreachable) {
rethrow;
}
throw const JellyfinException(JellyfinFailure.notJellyfin);
}
if (info case {
'Id': final String id,
'ServerName': final String name,
'Version': String _,
}) {
var quickConnect = false;
try {
quickConnect =
await _send('GET', address, '/QuickConnect/Enabled') == true;
} on JellyfinException {
// A server that doesn't say has it off.
}
return JellyfinServer(
address: address,
name: name,
id: id,
quickConnect: quickConnect,
);
}
throw const JellyfinException(JellyfinFailure.notJellyfin);
}
/// Starts a Quick Connect request (Jellyfin 10.9 and later).
Future<QuickConnectCode> initiateQuickConnect(Uri address) async {
try {
if (await _send('POST', address, '/QuickConnect/Initiate') case {
'Code': final String code,
'Secret': final String secret,
}) {
return (code: code, secret: secret);
}
} on JellyfinException catch (error) {
if (error.failure == JellyfinFailure.refused) {
throw const JellyfinException(JellyfinFailure.quickConnectOff);
}
rethrow;
}
throw const JellyfinException(JellyfinFailure.failed);
}
/// Whether the person approved the request [secret] belongs to.
Future<bool> quickConnectApproved(Uri address, String secret) async =>
switch (await _send(
'GET',
address,
'/QuickConnect/Connect',
query: {'Secret': secret},
)) {
{'Authenticated': true} => true,
_ => false,
};
/// Signs in with the approved request [secret] belongs to.
Future<JellyfinSignIn> authenticateWithQuickConnect(
Uri address,
String secret,
) async => _signedIn(
await _send(
'POST',
address,
'/Users/AuthenticateWithQuickConnect',
body: {'Secret': secret},
),
);
/// Signs in with [name] and [password].
Future<JellyfinSignIn> authenticateByName(
Uri address,
String name,
String password,
) async => _signedIn(
await _send(
'POST',
address,
'/Users/AuthenticateByName',
body: {'Username': name, 'Pw': password},
),
);
/// The person's playlists of music, by name; a playlist that names no
/// type counts as music, one of videos doesn't.
Future<List<JellyfinPlaylist>> playlists(
Uri address,
JellyfinSession session,
) async {
final answer = await _send(
'GET',
address,
'/Items',
token: session.token,
query: {
'userId': session.userId,
'includeItemTypes': 'Playlist',
'recursive': 'true',
'sortBy': 'SortName',
'fields': 'ChildCount',
},
);
return [
if (answer case {'Items': final List<Object?> items})
for (final item in items)
if (item
case {
'Id': final String id,
'Name': final String name,
}
when item['MediaType'] == null || item['MediaType'] == 'Audio')
JellyfinPlaylist(
id: id,
name: name,
tracks: item['ChildCount'] as int? ?? 0,
),
];
}
/// Ends [session]'s token on the server; what fails is left, as the
/// device forgets the token anyway.
Future<void> signOut(Uri address, JellyfinSession session) async {
try {
await _send('POST', address, '/Sessions/Logout', token: session.token);
} on JellyfinException {
// The server keeps it until its operator ends it.
}
}
JellyfinSignIn _signedIn(Object? answer) {
if (answer case {
'AccessToken': final String token,
'User': {'Id': final String userId, 'Name': final String userName},
}) {
return (token: token, userId: userId, userName: userName);
}
throw const JellyfinException(JellyfinFailure.failed);
}
String _authorization(String? token) =>
'MediaBrowser Client="Halkyon Train", Device="$device", '
'DeviceId="$deviceId", Version="$version"'
'${token == null ? '' : ', Token="$token"'}';
Future<Object?> _send(
String method,
Uri address,
String path, {
String? token,
Map<String, String>? query,
Map<String, Object?>? body,
}) async {
final request =
http.Request(
method,
address.replace(
path: '${address.path}$path',
queryParameters: query,
),
)
..headers['authorization'] = _authorization(token)
..headers['accept'] = 'application/json';
if (body != null) {
request
..headers['content-type'] = 'application/json'
..body = jsonEncode(body);
}
final http.Response response;
try {
response = await http.Response.fromStream(
await _http.send(request).timeout(_timeout),
).timeout(_timeout);
} on Object catch (error) {
if (error is http.ClientException ||
error is SocketException ||
error is TimeoutException ||
error is HandshakeException) {
throw const JellyfinException(JellyfinFailure.unreachable);
}
rethrow;
}
if (response.statusCode == 401 || response.statusCode == 403) {
throw const JellyfinException(JellyfinFailure.refused);
}
if (response.statusCode >= 300) {
throw const JellyfinException(JellyfinFailure.failed);
}
if (response.body.isEmpty) {
return null;
}
try {
return jsonDecode(response.body);
} on FormatException {
throw const JellyfinException(JellyfinFailure.notJellyfin);
}
}
}

View file

@ -0,0 +1,129 @@
import 'package:flutter/foundation.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/jellyfin_client.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
import 'package:http/http.dart' as http;
/// The person's music on Jellyfin, on this phone (ADR 0030, D47): the
/// server signed in to, with Quick Connect or a password, and the
/// playlists marked for running.
class MusicLibrary(
final MusicStore _store,
final JellyfinTokenStore _tokens, {
required final String version,
final http.Client? client,
}) {
late final ValueNotifier<MusicSettings> settings = ValueNotifier(
_store.read(),
);
late final jellyfin = JellyfinClient(
client ?? http.Client(),
deviceId: settings.value.deviceId,
version: version,
);
/// Forgets the tokens of people the device no longer keeps, and who is
/// signed in where their token is gone.
Future<void> open() async {
await _tokens.forgetOthers();
if (settings.value.signedIn && await _tokens.read() == null) {
_set(settings.value.signedOut());
}
}
/// What the server at [address] is.
Future<JellyfinServer> find(Uri address) => jellyfin.server(address);
/// Signs in to [server] with the Quick Connect request [secret] belongs
/// to, once approved.
Future<void> signInWithQuickConnect(
JellyfinServer server,
String secret,
) async {
await _signedIn(
server,
await jellyfin.authenticateWithQuickConnect(server.address, secret),
);
}
/// Signs in to [server] with [name] and [password].
Future<void> signInWithPassword(
JellyfinServer server,
String name,
String password,
) async {
await _signedIn(
server,
await jellyfin.authenticateByName(server.address, name, password),
);
}
/// The person's playlists of music; a token Jellyfin took back signs the
/// person out here too.
Future<List<JellyfinPlaylist>> playlists() async {
final current = settings.value;
final token = await _tokens.read();
if (!current.signedIn || token == null) {
throw const JellyfinException(JellyfinFailure.refused);
}
try {
return await jellyfin.playlists(current.address!, (
token: token,
userId: current.userId!,
));
} on JellyfinException catch (error) {
if (error.failure == JellyfinFailure.refused) {
await _tokens.keep(null);
_set(settings.value.signedOut());
}
rethrow;
}
}
/// Marks [playlist] for running, or no longer.
void mark(JellyfinPlaylist playlist, {required bool on}) {
final playlists = {...settings.value.playlists};
if (on) {
playlists[playlist.id] = playlist.name;
} else {
playlists.remove(playlist.id);
}
_set(settings.value.copyWith(playlists: playlists));
}
/// Signs out: the token ended on the server where it can be, forgotten
/// here, and the marked playlists with it.
Future<void> signOut() async {
final current = settings.value;
final token = await _tokens.read();
if (current.signedIn && token != null) {
await jellyfin.signOut(current.address!, (
token: token,
userId: current.userId!,
));
}
await _tokens.keep(null);
_set(current.signedOut());
}
void dispose() => settings.dispose();
Future<void> _signedIn(JellyfinServer server, JellyfinSignIn signedIn) async {
await _tokens.keep(signedIn.token);
_set(
MusicSettings(
deviceId: settings.value.deviceId,
address: server.address,
serverName: server.name,
serverId: server.id,
userId: signedIn.userId,
userName: signedIn.userName,
),
);
}
void _set(MusicSettings value) {
_store.save(value);
settings.value = value;
}
}

View file

@ -0,0 +1,157 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'dart:math' show Random;
import 'package:equatable/equatable.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Music from Jellyfin on this phone, for one person (ADR 0030, D47): never
/// synced, nothing of it on Train's server.
final class const MusicSettings({
/// The ID Jellyfin knows this phone's Train by, made once.
required final String deviceId,
/// The server signed in to; null before.
final Uri? address,
final String? serverName,
final String? serverId,
/// Who is signed in there; null before.
final String? userId,
final String? userName,
/// The playlists marked for running: their names by their IDs.
final Map<String, String> playlists = const {},
}) with Equatable {
/// Whether someone is signed in (their token in the keyring).
bool get signedIn => address != null && userId != null;
/// Signed out of the server: the device's ID kept, as Jellyfin lists
/// devices by it.
MusicSettings signedOut() => MusicSettings(deviceId: deviceId);
MusicSettings copyWith({Map<String, String>? playlists}) => MusicSettings(
deviceId: deviceId,
address: address,
serverName: serverName,
serverId: serverId,
userId: userId,
userName: userName,
playlists: playlists ?? this.playlists,
);
@override
List<Object?> get props => [
deviceId,
address,
serverName,
serverId,
userId,
userName,
playlists,
];
}
/// Keeps [MusicSettings] in a file of the person's folder (`music.json`); a
/// file it can't read is none, so the person signs in again.
class MusicStore(final File _file) {
MusicSettings read() {
if (_file.existsSync()) {
try {
if (jsonDecode(_file.readAsStringSync())
case {
'deviceId': final String deviceId,
} &&
final Map<String, Object?> json) {
return MusicSettings(
deviceId: deviceId,
address: switch (json['address']) {
final String address => Uri.tryParse(address),
_ => null,
},
serverName: json['serverName'] as String?,
serverId: json['serverId'] as String?,
userId: json['userId'] as String?,
userName: json['userName'] as String?,
playlists: {
...(json['playlists'] as Map<String, Object?>? ?? const {})
.cast<String, String>(),
},
);
}
} on Object catch (error) {
log('Music settings not read: ${error.runtimeType}', name: 'Music');
}
}
final settings = MusicSettings(deviceId: _newDeviceId());
save(settings);
return settings;
}
void save(MusicSettings settings) {
_file.parent.createSync(recursive: true);
_file.writeAsStringSync(
jsonEncode({
'deviceId': settings.deviceId,
'address': settings.address?.toString(),
'serverName': settings.serverName,
'serverId': settings.serverId,
'userId': settings.userId,
'userName': settings.userName,
'playlists': settings.playlists,
}),
);
}
static String _newDeviceId() {
final random = Random.secure();
return [
for (var i = 0; i < 16; i++)
random.nextInt(256).toRadixString(16).padLeft(2, '0'),
].join();
}
}
/// Where the person's Jellyfin token is kept on this phone.
abstract interface class JellyfinTokenStore() {
Future<String?> read();
/// Keeps [token]; null forgets it.
Future<void> keep(String? token);
/// Forgets the tokens of everyone else, whose folders the device no
/// longer keeps (one person at a time, as `PersonFolders.openOnly`).
Future<void> forgetOthers();
}
/// [JellyfinTokenStore] in the system's keyring (Android's Keystore), under
/// `train_jellyfin:` and the person's ID.
class SecureJellyfinTokenStore(
final String _person, {
final FlutterSecureStorage _storage = const FlutterSecureStorage(),
}) implements JellyfinTokenStore {
static const _prefix = 'train_jellyfin:';
String get _key => '$_prefix$_person';
@override
Future<String?> read() => _storage.read(key: _key);
@override
Future<void> keep(String? token) async {
await _storage.delete(key: _key);
if (token != null) {
await _storage.write(key: _key, value: token);
}
}
@override
Future<void> forgetOthers() async {
for (final key in (await _storage.readAll()).keys) {
if (key.startsWith(_prefix) && key != _key) {
await _storage.delete(key: key);
}
}
}
}

View file

@ -0,0 +1,287 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/feature/music/bloc/music_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/jellyfin_client.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Opens the music screen over [context]'s.
Future<void> openMusicScreen(BuildContext context) {
final music = PersonScope.of<TrainPerson>(context).music!;
return Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => BlocProvider(
create: (context) => MusicCubit(music)..open(),
child: const MusicScreen(),
),
),
);
}
/// Music from Jellyfin on this phone (ADR 0030, D47): signing in to the
/// person's server, with Quick Connect where it has it, else a password;
/// then their playlists, marked for running.
class const MusicScreen({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return BlocBuilder<MusicCubit, MusicState>(
builder: (context, state) => Scaffold(
appBar: AppBar(title: Text(l10n.musicTitle)),
body: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: readableContentMaxWidth,
),
child: switch (state.step) {
MusicStep.opening ||
MusicStep.finding ||
MusicStep.signingIn => const AppLoadingIndicator(),
MusicStep.address => _Address(failure: state.failure),
MusicStep.quickConnect => _QuickConnect(state: state),
MusicStep.password => _Password(state: state),
MusicStep.signedIn => _Playlists(state: state),
},
),
),
),
),
);
}
}
String _failureText(AppLocalizations l10n, MusicFailure failure) =>
switch (failure) {
MusicFailure.badAddress => l10n.musicBadAddress,
MusicFailure.unreachable => l10n.musicUnreachable,
MusicFailure.notJellyfin => l10n.musicNotJellyfin,
MusicFailure.refused => l10n.musicRefused,
MusicFailure.expired => l10n.musicCodeExpired,
MusicFailure.failed => l10n.musicFailed,
};
class const _Address({final MusicFailure? failure}) extends StatefulWidget {
@override
State<_Address> createState() => _AddressState();
}
class _AddressState() extends State<_Address> {
final _address = TextEditingController();
@override
void dispose() {
_address.dispose();
super.dispose();
}
void _find() => context.read<MusicCubit>().find(_address.text);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final failure = widget.failure;
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text(l10n.musicIntro),
const SizedBox(height: 16),
TextField(
controller: _address,
keyboardType: TextInputType.url,
autocorrect: false,
textInputAction: TextInputAction.go,
onSubmitted: (_) => _find(),
decoration: InputDecoration(
labelText: l10n.musicServer,
hintText: 'jellyfin.example.org',
errorText: failure == null ? null : _failureText(l10n, failure),
errorMaxLines: 3,
),
),
const SizedBox(height: 16),
Align(
alignment: AlignmentDirectional.centerEnd,
child: FilledButton(onPressed: _find, child: Text(l10n.musicConnect)),
),
],
);
}
}
class const _QuickConnect({required final MusicState state})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final code = state.code;
final failure = state.failure;
MusicCubit cubit() => context.read<MusicCubit>();
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text(l10n.musicQuickConnect(state.server!.name)),
const SizedBox(height: 24),
if (code != null) ...[
Center(
child: SelectableText(
code,
style: Theme.of(context).textTheme.displaySmall,
),
),
const SizedBox(height: 16),
const LinearProgressIndicator(),
] else if (failure != null) ...[
Text(_failureText(l10n, failure)),
const SizedBox(height: 16),
Align(
alignment: AlignmentDirectional.centerEnd,
child: FilledButton(
onPressed: () => cubit().newCode(),
child: Text(l10n.musicNewCode),
),
),
],
const SizedBox(height: 24),
OverflowBar(
alignment: MainAxisAlignment.end,
spacing: 8,
children: [
TextButton(
onPressed: () => cubit().back(),
child: Text(l10n.musicBack),
),
TextButton(
onPressed: () => cubit().usePassword(),
child: Text(l10n.musicUsePassword),
),
],
),
],
);
}
}
class const _Password({required final MusicState state})
extends StatefulWidget {
@override
State<_Password> createState() => _PasswordState();
}
class _PasswordState() extends State<_Password> {
final _name = TextEditingController();
final _password = TextEditingController();
@override
void dispose() {
_name.dispose();
_password.dispose();
super.dispose();
}
void _signIn() =>
context.read<MusicCubit>().signIn(_name.text.trim(), _password.text);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final failure = widget.state.failure;
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text(l10n.musicPasswordIntro(widget.state.server!.name)),
const SizedBox(height: 16),
TextField(
controller: _name,
autocorrect: false,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.username],
decoration: InputDecoration(labelText: l10n.musicUserName),
),
const SizedBox(height: 8),
TextField(
controller: _password,
obscureText: true,
textInputAction: TextInputAction.go,
autofillHints: const [AutofillHints.password],
onSubmitted: (_) => _signIn(),
decoration: InputDecoration(
labelText: l10n.musicPassword,
errorText: failure == null ? null : _failureText(l10n, failure),
errorMaxLines: 3,
),
),
const SizedBox(height: 16),
OverflowBar(
alignment: MainAxisAlignment.end,
spacing: 8,
children: [
TextButton(
onPressed: () => context.read<MusicCubit>().back(),
child: Text(l10n.musicBack),
),
FilledButton(onPressed: _signIn, child: Text(l10n.musicSignIn)),
],
),
],
);
}
}
class const _Playlists({required final MusicState state})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final settings = state.settings;
final playlists = state.playlists;
final failure = state.failure;
MusicCubit cubit() => context.read<MusicCubit>();
return ListView(
children: [
ListTile(
leading: const Icon(LucideIcons.server),
title: Text(settings.serverName ?? '${settings.address}'),
subtitle: Text(l10n.musicSignedInAs(settings.userName ?? '')),
trailing: TextButton(
onPressed: () => cubit().signOut(),
child: Text(l10n.musicSignOut),
),
),
const Divider(),
ListTile(
title: Text(
l10n.musicPlaylists,
style: Theme.of(context).textTheme.titleSmall,
),
subtitle: Text(l10n.musicPlaylistsHint),
),
if (failure != null)
ListTile(
title: Text(_failureText(l10n, failure)),
trailing: TextButton(
onPressed: () => cubit().loadPlaylists(),
child: Text(l10n.musicRetry),
),
)
else if (playlists == null)
const Padding(
padding: EdgeInsets.all(16),
child: AppLoadingIndicator(),
)
else if (playlists.isEmpty)
ListTile(subtitle: Text(l10n.musicNoPlaylists)),
for (final playlist in playlists ?? const <JellyfinPlaylist>[])
CheckboxListTile(
title: Text(playlist.name),
subtitle: Text(l10n.musicTracks(playlist.tracks)),
value: settings.playlists.containsKey(playlist.id),
onChanged: (on) => cubit().mark(playlist, on: on ?? false),
),
],
);
}
}

View file

@ -30,6 +30,9 @@ dependencies:
# vector tiles of the instance's source (OpenFreeMap without a setting, D22),
# and its coordinates.
flutter_map: ^8.3.2
# The Jellyfin token in the keyring (ADR 0030, D47), as halkyon_app keeps
# the account's keys.
flutter_secure_storage: ^10.0.0
halkyon_app:
path: ../../../packages/halkyon_app
# The device's half of signing in with OpenID Connect (ADR 0023): the

View file

@ -0,0 +1,263 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/jellyfin_client.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
/// Jellyfin as Train asks it (ADR 0030, D47): what the server is, signing in
/// with Quick Connect or a password, and the person's playlists.
void main() {
final address = Uri.parse('https://music.example.org/jellyfin');
/// A Jellyfin server answering [routes] by path, as JSON; every request it
/// saw in [seen].
JellyfinClient serving(
Map<String, (int, Object?)> routes, {
List<http.Request>? seen,
}) => JellyfinClient(
MockClient((request) async {
seen?.add(request);
final route = routes['${request.method} ${request.url.path}'];
if (route == null) {
return http.Response('', 404);
}
return http.Response(
jsonEncode(route.$2),
route.$1,
headers: {'content-type': 'application/json'},
);
}),
deviceId: 'device-1',
version: '0.1.0',
);
group('an address as typed', () {
test('becomes https without a scheme, keeps its path, drops a trailing '
'slash', () {
expect(
jellyfinAddress(' music.example.org/jellyfin/ '),
Uri.parse('https://music.example.org/jellyfin'),
);
expect(
jellyfinAddress('http://192.168.1.4:8096'),
Uri.parse('http://192.168.1.4:8096'),
);
});
test('is none without a host or with another scheme', () {
expect(jellyfinAddress(''), isNull);
expect(jellyfinAddress('ftp://music.example.org'), isNull);
expect(jellyfinAddress('https://'), isNull);
});
});
test('a server says its name and whether Quick Connect is on', () async {
final seen = <http.Request>[];
final client = serving({
'GET /jellyfin/System/Info/Public': (
200,
{'ServerName': 'Home', 'Id': 'server-1', 'Version': '10.10.7'},
),
'GET /jellyfin/QuickConnect/Enabled': (200, true),
}, seen: seen);
final server = await client.server(address);
expect(server.name, 'Home');
expect(server.id, 'server-1');
expect(server.quickConnect, isTrue);
expect(
seen.first.headers['authorization'],
'MediaBrowser Client="Halkyon Train", Device="Android", '
'DeviceId="device-1", Version="0.1.0"',
);
});
test("what answers without Jellyfin's basics is not Jellyfin, and what "
"doesn't answer is unreachable", () async {
await expectLater(
serving({
'GET /jellyfin/System/Info/Public': (200, {'hello': 'world'}),
}).server(address),
throwsA(
isA<JellyfinException>().having(
(e) => e.failure,
'failure',
JellyfinFailure.notJellyfin,
),
),
);
await expectLater(
serving({}).server(address),
throwsA(
isA<JellyfinException>().having(
(e) => e.failure,
'failure',
JellyfinFailure.notJellyfin,
),
),
);
final down = JellyfinClient(
MockClient((request) => throw http.ClientException('down')),
deviceId: 'device-1',
version: '0.1.0',
);
await expectLater(
down.server(address),
throwsA(
isA<JellyfinException>().having(
(e) => e.failure,
'failure',
JellyfinFailure.unreachable,
),
),
);
});
test(
'Quick Connect: a code to show, approved, then a token of its own',
() async {
final seen = <http.Request>[];
var approved = false;
final client = JellyfinClient(
MockClient((request) async {
seen.add(request);
final body = switch ('${request.method} ${request.url.path}') {
'POST /jellyfin/QuickConnect/Initiate' => {
'Secret': 'secret-1',
'Code': '123456',
},
'GET /jellyfin/QuickConnect/Connect' => {'Authenticated': approved},
'POST /jellyfin/Users/AuthenticateWithQuickConnect' => {
'AccessToken': 'token-1',
'User': {'Id': 'user-1', 'Name': 'Ada'},
},
_ => null,
};
return http.Response(jsonEncode(body), body == null ? 404 : 200);
}),
deviceId: 'device-1',
version: '0.1.0',
);
final code = await client.initiateQuickConnect(address);
expect(code.code, '123456');
expect(await client.quickConnectApproved(address, code.secret), isFalse);
approved = true;
expect(await client.quickConnectApproved(address, code.secret), isTrue);
final signedIn = await client.authenticateWithQuickConnect(
address,
code.secret,
);
expect(signedIn, (token: 'token-1', userId: 'user-1', userName: 'Ada'));
expect(seen[1].url.queryParameters, {'Secret': 'secret-1'});
expect(jsonDecode(seen.last.body), {'Secret': 'secret-1'});
},
);
test('Quick Connect switched off is said so', () async {
await expectLater(
serving({
'POST /jellyfin/QuickConnect/Initiate': (401, null),
}).initiateQuickConnect(address),
throwsA(
isA<JellyfinException>().having(
(e) => e.failure,
'failure',
JellyfinFailure.quickConnectOff,
),
),
);
});
test('a password signs in; a wrong one is refused', () async {
final seen = <http.Request>[];
final client = serving({
'POST /jellyfin/Users/AuthenticateByName': (
200,
{
'AccessToken': 'token-2',
'User': {'Id': 'user-2', 'Name': 'Grace'},
},
),
}, seen: seen);
expect(
await client.authenticateByName(address, 'Grace', 'pw'),
(token: 'token-2', userId: 'user-2', userName: 'Grace'),
);
expect(jsonDecode(seen.single.body), {'Username': 'Grace', 'Pw': 'pw'});
await expectLater(
serving({
'POST /jellyfin/Users/AuthenticateByName': (401, null),
}).authenticateByName(address, 'Grace', 'wrong'),
throwsA(
isA<JellyfinException>().having(
(e) => e.failure,
'failure',
JellyfinFailure.refused,
),
),
);
});
test(
"the person's playlists of music, with the token; videos' left out",
() async {
final seen = <http.Request>[];
final client = serving({
'GET /jellyfin/Items': (
200,
{
'Items': [
{
'Id': 'p1',
'Name': 'Tempo',
'MediaType': 'Audio',
'ChildCount': 42,
},
{'Id': 'p2', 'Name': 'Films', 'MediaType': 'Video'},
{'Id': 'p3', 'Name': 'Old'},
],
},
),
}, seen: seen);
const session = (token: 'token-1', userId: 'user-1');
final playlists = await client.playlists(address, session);
expect(playlists, const [
JellyfinPlaylist(id: 'p1', name: 'Tempo', tracks: 42),
JellyfinPlaylist(id: 'p3', name: 'Old'),
]);
expect(seen.single.url.queryParameters, {
'userId': 'user-1',
'includeItemTypes': 'Playlist',
'recursive': 'true',
'sortBy': 'SortName',
'fields': 'ChildCount',
});
expect(
seen.single.headers['authorization'],
endsWith(', Token="token-1"'),
);
},
);
test('a token Jellyfin no longer takes is refused', () async {
await expectLater(
serving({
'GET /jellyfin/Items': (401, null),
}).playlists(address, (token: 'old', userId: 'user-1')),
throwsA(
isA<JellyfinException>().having(
(e) => e.failure,
'failure',
JellyfinFailure.refused,
),
),
);
});
}

View file

@ -0,0 +1,126 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/jellyfin_client.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_library.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
import '../../support/fake_jellyfin.dart';
/// The person's Jellyfin on this phone (ADR 0030, D47): signed in, the
/// token in the keyring and nothing else of it in the file, the playlists
/// marked, signed out.
void main() {
late Directory folder;
late File file;
late FakeJellyfin jellyfin;
late MemoryJellyfinTokenStore tokens;
final address = Uri.parse('https://music.example.org');
setUp(() {
folder = Directory.systemTemp.createTempSync('music_');
file = File('${folder.path}/music.json');
jellyfin = FakeJellyfin();
tokens = MemoryJellyfinTokenStore();
});
tearDown(() => folder.deleteSync(recursive: true));
MusicLibrary open() => MusicLibrary(
MusicStore(file),
tokens,
version: '0.1.0',
client: jellyfin.client,
);
test('a device ID is made once and kept, and Jellyfin is told it', () async {
final first = open();
final id = first.settings.value.deviceId;
expect(id, hasLength(32));
expect(open().settings.value.deviceId, id);
await first.find(address);
expect(
jellyfin.requests.first.headers['authorization'],
contains('DeviceId="$id"'),
);
});
test('signed in with a password: the token in the keyring, never in the '
'file; playlists marked and kept', () async {
final library = open();
final server = await library.find(address);
await library.signInWithPassword(server, 'Ada', 'pw');
expect(tokens.token, 'token-1');
expect(file.readAsStringSync(), isNot(contains('token-1')));
expect(library.settings.value.signedIn, isTrue);
expect(library.settings.value.userName, 'Ada');
expect(library.settings.value.serverName, 'Home');
final playlists = await library.playlists();
library.mark(playlists.first, on: true);
expect(open().settings.value.playlists, {'p1': 'Tempo'});
library.mark(playlists.first, on: false);
expect(open().settings.value.playlists, isEmpty);
});
test('a wrong password is refused, and nothing is kept', () async {
final library = open();
final server = await library.find(address);
await expectLater(
library.signInWithPassword(server, 'Ada', 'wrong'),
throwsA(isA<JellyfinException>()),
);
expect(tokens.token, isNull);
expect(library.settings.value.signedIn, isFalse);
});
test('signing out ends the token on the server and forgets it and the '
'playlists here', () async {
final library = open();
await library.signInWithPassword(await library.find(address), 'Ada', 'pw');
library.mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true);
await library.signOut();
expect(jellyfin.valid, isEmpty);
expect(tokens.token, isNull);
expect(library.settings.value.signedIn, isFalse);
expect(library.settings.value.playlists, isEmpty);
});
test('a token Jellyfin took back signs the person out here', () async {
final library = open();
await library.signInWithPassword(await library.find(address), 'Ada', 'pw');
jellyfin.revoke();
await expectLater(library.playlists(), throwsA(isA<JellyfinException>()));
expect(tokens.token, isNull);
expect(library.settings.value.signedIn, isFalse);
});
test(
"opening forgets others' tokens, and a sign-in whose token is gone",
() async {
final library = open();
await library.signInWithPassword(
await library.find(address),
'Ada',
'pw',
);
tokens.token = null;
final again = open();
await again.open();
expect(tokens.others, isEmpty);
expect(again.settings.value.signedIn, isFalse);
},
);
test("a file that can't be read is none", () {
file.writeAsStringSync('{not json');
expect(open().settings.value.signedIn, isFalse);
});
}

View file

@ -0,0 +1,159 @@
import 'dart:io';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/feature/music/bloc/music_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_library.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
import 'package:halkyon_train_flutter/src/feature/music/widget/music_screen.dart';
import 'package:material_ui/material_ui.dart';
import '../../support/fake_jellyfin.dart';
/// The music screen (ADR 0030, D47): the server's address, Quick Connect's
/// code until it is approved, else the password; then the playlists marked.
void main() {
late Directory folder;
late MemoryJellyfinTokenStore tokens;
setUp(() {
folder = Directory.systemTemp.createTempSync('music_');
tokens = MemoryJellyfinTokenStore();
});
tearDown(() => folder.deleteSync(recursive: true));
Future<MusicLibrary> show(WidgetTester tester, FakeJellyfin jellyfin) async {
final library = MusicLibrary(
MusicStore(File('${folder.path}/music.json')),
tokens,
version: '0.1.0',
client: jellyfin.client,
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: BlocProvider(
create: (context) => MusicCubit(
library,
poll: const Duration(milliseconds: 100),
)..open(),
child: const MusicScreen(),
),
),
);
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
await tester.pumpAndSettle();
return library;
}
/// Lets the fake server's answers and the screen's timers run.
Future<void> settle(WidgetTester tester) async {
for (var i = 0; i < 5; i++) {
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
await tester.pump(const Duration(milliseconds: 150));
}
}
testWidgets('Quick Connect: the code shown until approved, then the '
"person's playlists, one marked", (tester) async {
final jellyfin = FakeJellyfin();
final library = await show(tester, jellyfin);
await tester.enterText(find.byType(TextField), 'music.example.org');
await tester.tap(find.text('Connect'));
await settle(tester);
expect(find.text('123456'), findsOneWidget);
expect(find.textContaining('To sign in to Home'), findsOneWidget);
jellyfin.approve();
await settle(tester);
expect(find.text('Signed in as Ada'), findsOneWidget);
expect(find.text('Tempo'), findsOneWidget);
expect(find.text('42 tracks'), findsOneWidget);
await tester.tap(find.text('Tempo'));
await tester.pump();
expect(library.settings.value.playlists, {'p1': 'Tempo'});
await tester.tap(find.text('Sign out'));
await settle(tester);
expect(find.text('Jellyfin server'), findsOneWidget);
expect(tokens.token, isNull);
});
testWidgets('without Quick Connect, the password; a wrong one said so', (
tester,
) async {
await show(tester, FakeJellyfin(quickConnect: false));
await tester.enterText(find.byType(TextField), 'music.example.org');
await tester.tap(find.text('Connect'));
await settle(tester);
expect(find.text('User name'), findsOneWidget);
await tester.enterText(find.byType(TextField).first, 'Ada');
await tester.enterText(find.byType(TextField).last, 'wrong');
await tester.tap(find.text('Sign in'));
await settle(tester);
expect(find.textContaining('Jellyfin refused'), findsOneWidget);
await tester.enterText(find.byType(TextField).last, 'pw');
await tester.tap(find.text('Sign in'));
await settle(tester);
expect(find.text('Signed in as Ada'), findsOneWidget);
});
testWidgets('an address that names no server is said so, and nothing is '
'asked', (tester) async {
final jellyfin = FakeJellyfin();
await show(tester, jellyfin);
await tester.enterText(find.byType(TextField), 'ftp://x');
await tester.tap(find.text('Connect'));
await tester.pump();
expect(find.textContaining("Enter the server's address"), findsOneWidget);
expect(jellyfin.requests, isEmpty);
});
testWidgets('a code not approved in time expires, and a new one is asked '
'for', (tester) async {
final jellyfin = FakeJellyfin();
final library = MusicLibrary(
MusicStore(File('${folder.path}/music.json')),
tokens,
version: '0.1.0',
client: jellyfin.client,
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: BlocProvider(
create: (context) => MusicCubit(
library,
poll: const Duration(milliseconds: 100),
expiry: Duration.zero,
)..open(),
child: const MusicScreen(),
),
),
);
await settle(tester);
await tester.enterText(find.byType(TextField), 'music.example.org');
await tester.tap(find.text('Connect'));
await settle(tester);
expect(find.text('The code has expired.'), findsOneWidget);
await tester.tap(find.text('New code'));
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
await tester.pump();
expect(find.text('123456'), findsOneWidget);
});
}

View file

@ -0,0 +1,99 @@
import 'dart:convert';
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
/// A Jellyfin server for tests (ADR 0030, D47), at
/// `https://music.example.org`: Ada signs in with the password `pw` or with
/// Quick Connect once [approve]d; her token `token-<n>` holds until
/// [revoke]d.
class FakeJellyfin({
/// Whether it signs in with Quick Connect.
final bool quickConnect = true,
}) {
final requests = <http.Request>[];
var _approved = false;
var _tokens = 0;
final _valid = <String>{};
List<Map<String, Object?>> playlists = [
{'Id': 'p1', 'Name': 'Tempo', 'MediaType': 'Audio', 'ChildCount': 42},
{'Id': 'p2', 'Name': 'Easy', 'MediaType': 'Audio', 'ChildCount': 1},
];
void approve() => _approved = true;
void revoke() => _valid.clear();
/// Tokens given and still valid.
Set<String> get valid => {..._valid};
late final http.Client client = MockClient((request) async {
requests.add(request);
final token = RegExp(
'Token="([^"]+)"',
).firstMatch(request.headers['authorization'] ?? '')?.group(1);
http.Response json(Object? body, [int status = 200]) =>
http.Response(jsonEncode(body), status);
String signIn() {
final token = 'token-${++_tokens}';
_valid.add(token);
return token;
}
switch ('${request.method} ${request.url.path}') {
case 'GET /System/Info/Public':
return json({
'ServerName': 'Home',
'Id': 'server-1',
'Version': '10.10.7',
});
case 'GET /QuickConnect/Enabled':
return json(quickConnect);
case 'POST /QuickConnect/Initiate':
return quickConnect
? json({'Code': '123456', 'Secret': 'secret-1'})
: json(null, 401);
case 'GET /QuickConnect/Connect':
return json({'Authenticated': _approved});
case 'POST /Users/AuthenticateWithQuickConnect':
return _approved
? json({
'AccessToken': signIn(),
'User': {'Id': 'user-1', 'Name': 'Ada'},
})
: json(null, 401);
case 'POST /Users/AuthenticateByName':
final body = jsonDecode(request.body) as Map<String, Object?>;
return body['Username'] == 'Ada' && body['Pw'] == 'pw'
? json({
'AccessToken': signIn(),
'User': {'Id': 'user-1', 'Name': 'Ada'},
})
: json(null, 401);
case 'GET /Items':
return _valid.contains(token)
? json({'Items': playlists})
: json(null, 401);
case 'POST /Sessions/Logout':
_valid.remove(token);
return http.Response('', 204);
}
return http.Response('', 404);
});
}
/// A [JellyfinTokenStore] in memory, with others' tokens in [others].
class MemoryJellyfinTokenStore() implements JellyfinTokenStore {
String? token;
final others = <String>{'someone-else'};
@override
Future<String?> read() async => token;
@override
Future<void> keep(String? token) async => this.token = token;
@override
Future<void> forgetOthers() async => others.clear();
}