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:
parent
64366d5420
commit
6bde74f0ba
8 changed files with 342 additions and 18 deletions
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,99 @@
|
||||||
|
package app.halkyon.train
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import android.util.Log
|
||||||
|
import java.io.File
|
||||||
|
import java.security.KeyStore
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The person's Jellyfin token, in one store of Train's (ADR 0030, D52), which the app reads over
|
||||||
|
* the music channel and the music job reads directly: a file per person in [folder], sealed
|
||||||
|
* with AES-GCM under [key] (a key Android's Keystore holds and never lets out), the person's ID
|
||||||
|
* as its associated data, so that a file under another's name opens for no one. A file that
|
||||||
|
* doesn't open (the key gone with a restore) is none, and deleted.
|
||||||
|
*/
|
||||||
|
class JellyfinTokens(
|
||||||
|
private val folder: File,
|
||||||
|
private val key: () -> SecretKey,
|
||||||
|
private val warn: (String) -> Unit = { Log.w("Music", it) },
|
||||||
|
) {
|
||||||
|
fun read(person: String): String? {
|
||||||
|
val file = fileOf(person)
|
||||||
|
if (!file.exists()) return null
|
||||||
|
return try {
|
||||||
|
val bytes = file.readBytes()
|
||||||
|
require(bytes.size > 1 + IV_BYTES && bytes[0] == VERSION)
|
||||||
|
val cipher = Cipher.getInstance(CIPHER)
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(TAG_BITS, bytes, 1, IV_BYTES))
|
||||||
|
cipher.updateAAD(person.toByteArray())
|
||||||
|
String(cipher.doFinal(bytes, 1 + IV_BYTES, bytes.size - 1 - IV_BYTES), Charsets.UTF_8)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
warn("The Jellyfin token not read: ${error.javaClass.simpleName}")
|
||||||
|
file.delete()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps [token] for [person]; null forgets it. */
|
||||||
|
fun keep(person: String, token: String?) {
|
||||||
|
val file = fileOf(person)
|
||||||
|
if (token == null) {
|
||||||
|
file.delete()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val cipher = Cipher.getInstance(CIPHER)
|
||||||
|
// The Keystore makes the IV itself.
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, key())
|
||||||
|
cipher.updateAAD(person.toByteArray())
|
||||||
|
val sealed = cipher.doFinal(token.toByteArray(Charsets.UTF_8))
|
||||||
|
folder.mkdirs()
|
||||||
|
val part = File(folder, "$person.part")
|
||||||
|
part.writeBytes(byteArrayOf(VERSION) + cipher.iv + sealed)
|
||||||
|
check(part.renameTo(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forgets everyone's token but [person]'s (one person's folder at a time). */
|
||||||
|
fun forgetOthers(person: String) {
|
||||||
|
fileOf(person)
|
||||||
|
folder.listFiles()?.filter { it.name != person }?.forEach { it.delete() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fileOf(person: String): File {
|
||||||
|
require(Regex("[A-Za-z0-9-]+").matches(person)) { "Not a person's ID" }
|
||||||
|
return File(folder, person)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val VERSION: Byte = 1
|
||||||
|
private const val IV_BYTES = 12
|
||||||
|
private const val TAG_BITS = 128
|
||||||
|
private const val CIPHER = "AES/GCM/NoPadding"
|
||||||
|
private const val ALIAS = "train_jellyfin"
|
||||||
|
|
||||||
|
fun of(context: Context) = JellyfinTokens(File(context.noBackupFilesDir, "jellyfin"), ::keystoreKey)
|
||||||
|
|
||||||
|
/** Train's key for the tokens in Android's Keystore, made once. */
|
||||||
|
@Synchronized
|
||||||
|
private fun keystoreKey(): SecretKey {
|
||||||
|
val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||||
|
(store.getKey(ALIAS, null) as SecretKey?)?.let { return it }
|
||||||
|
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
|
||||||
|
.apply {
|
||||||
|
init(
|
||||||
|
KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
||||||
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||||
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||||
|
.setKeySize(256)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.generateKey()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,13 +17,15 @@ import java.util.concurrent.Executors
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Music's side (ADR 0030, D47), over `app.halkyon.train/music`: whether the network is metered,
|
* Music's side (ADR 0030, D47), over `app.halkyon.train/music`: whether the network is metered,
|
||||||
* as music is downloaded on unmetered ones only; and a downloaded track's tempo, the tag its
|
* as music is downloaded on unmetered ones only; a downloaded track's tempo, the tag its
|
||||||
* file names, else a minute from its middle decoded by Android's own decoder, mixed down to
|
* file names, else a minute from its middle decoded by Android's own decoder, mixed down to
|
||||||
* mono and handed to [Tempo], one track at a time, off the main thread.
|
* mono and handed to [Tempo], one track at a time, off the main thread; and the person's
|
||||||
|
* Jellyfin token, in [JellyfinTokens] (D52).
|
||||||
*/
|
*/
|
||||||
class MusicBridge(private val context: Context) : MethodChannel.MethodCallHandler {
|
class MusicBridge(private val context: Context) : MethodChannel.MethodCallHandler {
|
||||||
private val worker = Executors.newSingleThreadExecutor()
|
private val worker = Executors.newSingleThreadExecutor()
|
||||||
private val main = Handler(Looper.getMainLooper())
|
private val main = Handler(Looper.getMainLooper())
|
||||||
|
private val tokens by lazy { JellyfinTokens.of(context) }
|
||||||
|
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
|
|
@ -45,6 +47,15 @@ class MusicBridge(private val context: Context) : MethodChannel.MethodCallHandle
|
||||||
main.post { result.success(tempo) }
|
main.post { result.success(tempo) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"tokenRead" -> result.success(tokens.read(call.argument<String>("person")!!))
|
||||||
|
"tokenKeep" -> {
|
||||||
|
tokens.keep(call.argument<String>("person")!!, call.argument<String>("token"))
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"tokenForgetOthers" -> {
|
||||||
|
tokens.forgetOthers(call.argument<String>("person")!!)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
package app.halkyon.train
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.file.Files
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
// The person's Jellyfin token in one store of Train's (ADR 0030, D52): a
|
||||||
|
// file per person, sealed with AES-GCM under a key the Keystore holds (a
|
||||||
|
// software key here), unreadable without it, bound to its person.
|
||||||
|
class JellyfinTokensTest {
|
||||||
|
private val folder: File = Files.createTempDirectory("tokens").toFile()
|
||||||
|
private val key = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||||
|
private val tokens = JellyfinTokens(folder, { key }, warn = {})
|
||||||
|
|
||||||
|
@After fun tearDown() = folder.deleteRecursively().let {}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a token kept is read back, and its file never holds it in the clear`() {
|
||||||
|
tokens.keep("person-1", "secret-token")
|
||||||
|
|
||||||
|
assertEquals("secret-token", tokens.read("person-1"))
|
||||||
|
val bytes = File(folder, "person-1").readBytes()
|
||||||
|
assertFalse(String(bytes, Charsets.ISO_8859_1).contains("secret-token"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `none kept is null, and null forgets it`() {
|
||||||
|
assertNull(tokens.read("person-1"))
|
||||||
|
tokens.keep("person-1", "secret-token")
|
||||||
|
tokens.keep("person-1", null)
|
||||||
|
|
||||||
|
assertNull(tokens.read("person-1"))
|
||||||
|
assertFalse(File(folder, "person-1").exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `another key reads nothing, and the file it can't open is deleted`() {
|
||||||
|
tokens.keep("person-1", "secret-token")
|
||||||
|
val other = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||||
|
|
||||||
|
assertNull(JellyfinTokens(folder, { other }, warn = {}).read("person-1"))
|
||||||
|
assertFalse(File(folder, "person-1").exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `one person's file moved to another's name is not theirs`() {
|
||||||
|
tokens.keep("person-1", "secret-token")
|
||||||
|
File(folder, "person-1").renameTo(File(folder, "person-2"))
|
||||||
|
|
||||||
|
assertNull(tokens.read("person-2"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the others' tokens are forgotten, the person's kept`() {
|
||||||
|
tokens.keep("person-1", "one")
|
||||||
|
tokens.keep("person-2", "two")
|
||||||
|
tokens.forgetOthers("person-1")
|
||||||
|
|
||||||
|
assertEquals("one", tokens.read("person-1"))
|
||||||
|
assertNull(tokens.read("person-2"))
|
||||||
|
assertEquals(listOf("person-1"), folder.list()!!.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an ID that could name another file is refused`() {
|
||||||
|
assertTrue(runCatching { tokens.keep("../escape", "x") }.isFailure)
|
||||||
|
assertTrue(runCatching { tokens.read("a/b") }.isFailure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -426,7 +426,7 @@ Future<TrainPerson> _openPersonData(
|
||||||
final music = Platform.isAndroid
|
final music = Platform.isAndroid
|
||||||
? MusicLibrary(
|
? MusicLibrary(
|
||||||
MusicStore(File(p.join(folder.path, 'music.json'))),
|
MusicStore(File(p.join(folder.path, 'music.json'))),
|
||||||
SecureJellyfinTokenStore(person.authUserId),
|
AndroidJellyfinTokenStore(person.authUserId),
|
||||||
version: appVersion,
|
version: appVersion,
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import 'dart:io';
|
||||||
import 'dart:math' show Random;
|
import 'dart:math' show Random;
|
||||||
|
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
/// Music from Jellyfin on this phone, for one person (ADR 0030, D47): never
|
/// Music from Jellyfin on this phone, for one person (ADR 0030, D47): never
|
||||||
|
|
@ -137,32 +138,50 @@ abstract interface class JellyfinTokenStore() {
|
||||||
Future<void> forgetOthers();
|
Future<void> forgetOthers();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [JellyfinTokenStore] in the system's keyring (Android's Keystore), under
|
/// [JellyfinTokenStore] in Train's own store on Android (ADR 0030, D52),
|
||||||
/// `train_jellyfin:` and the person's ID.
|
/// over `app.halkyon.train/music` (`JellyfinTokens.kt`: sealed under a key
|
||||||
class SecureJellyfinTokenStore(
|
/// 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 String _person, {
|
||||||
final FlutterSecureStorage _storage = const FlutterSecureStorage(),
|
final MethodChannel _channel = const MethodChannel('app.halkyon.train/music'),
|
||||||
|
final FlutterSecureStorage _keyring = const FlutterSecureStorage(),
|
||||||
}) implements JellyfinTokenStore {
|
}) implements JellyfinTokenStore {
|
||||||
static const _prefix = 'train_jellyfin:';
|
static const _keyringPrefix = 'train_jellyfin:';
|
||||||
|
|
||||||
String get _key => '$_prefix$_person';
|
String get _keyringKey => '$_keyringPrefix$_person';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
Future<void> keep(String? token) async {
|
Future<void> keep(String? token) async {
|
||||||
await _storage.delete(key: _key);
|
await _channel.invokeMethod<void>('tokenKeep', {
|
||||||
if (token != null) {
|
'person': _person,
|
||||||
await _storage.write(key: _key, value: token);
|
'token': token,
|
||||||
}
|
});
|
||||||
|
await _keyring.delete(key: _keyringKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> forgetOthers() async {
|
Future<void> forgetOthers() async {
|
||||||
for (final key in (await _storage.readAll()).keys) {
|
await _channel.invokeMethod<void>('tokenForgetOthers', {'person': _person});
|
||||||
if (key.startsWith(_prefix) && key != _key) {
|
for (final key in [...(await _keyring.readAll()).keys]) {
|
||||||
await _storage.delete(key: key);
|
if (key.startsWith(_keyringPrefix) && key != _keyringKey) {
|
||||||
|
await _keyring.delete(key: key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
|
||||||
|
|
||||||
|
/// The Jellyfin token in Train's own store on Android (ADR 0030, D52), over
|
||||||
|
/// the music channel (`JellyfinTokens.kt`, a store in memory here); a token
|
||||||
|
/// the keyring kept before is moved over once and leaves the keyring.
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
const channel = MethodChannel('app.halkyon.train/music');
|
||||||
|
late Map<String, String> kept;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
kept = {};
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(channel, (call) async {
|
||||||
|
final arguments = call.arguments as Map<Object?, Object?>;
|
||||||
|
final person = arguments['person']! as String;
|
||||||
|
switch (call.method) {
|
||||||
|
case 'tokenRead':
|
||||||
|
return kept[person];
|
||||||
|
case 'tokenKeep':
|
||||||
|
if (arguments['token'] case final String token) {
|
||||||
|
kept[person] = token;
|
||||||
|
} else {
|
||||||
|
kept.remove(person);
|
||||||
|
}
|
||||||
|
case 'tokenForgetOthers':
|
||||||
|
kept.removeWhere((key, _) => key != person);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(
|
||||||
|
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(channel, null),
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'a token kept is read back from the store, and null forgets it',
|
||||||
|
() async {
|
||||||
|
FlutterSecureStorage.setMockInitialValues({});
|
||||||
|
final tokens = AndroidJellyfinTokenStore('ada');
|
||||||
|
|
||||||
|
await tokens.keep('token-1');
|
||||||
|
expect(await tokens.read(), 'token-1');
|
||||||
|
expect(kept, {'ada': 'token-1'});
|
||||||
|
|
||||||
|
await tokens.keep(null);
|
||||||
|
expect(await tokens.read(), isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("the keyring's token is moved over once and leaves the keyring; "
|
||||||
|
"others' are forgotten in both", () async {
|
||||||
|
FlutterSecureStorage.setMockInitialValues({
|
||||||
|
'train_jellyfin:ada': 'before',
|
||||||
|
'train_jellyfin:bob': 'his',
|
||||||
|
'halkyon_account_key': 'not music',
|
||||||
|
});
|
||||||
|
kept['bob'] = 'his';
|
||||||
|
final tokens = AndroidJellyfinTokenStore('ada');
|
||||||
|
|
||||||
|
await tokens.forgetOthers();
|
||||||
|
expect(await tokens.read(), 'before');
|
||||||
|
|
||||||
|
const keyring = FlutterSecureStorage();
|
||||||
|
expect(await keyring.readAll(), {'halkyon_account_key': 'not music'});
|
||||||
|
expect(kept, {'ada': 'before'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a token kept anew leaves no old one in the keyring', () async {
|
||||||
|
FlutterSecureStorage.setMockInitialValues({'train_jellyfin:ada': 'before'});
|
||||||
|
final tokens = AndroidJellyfinTokenStore('ada');
|
||||||
|
|
||||||
|
await tokens.keep('after');
|
||||||
|
expect(await tokens.read(), 'after');
|
||||||
|
expect(await const FlutterSecureStorage().readAll(), isEmpty);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1310,6 +1310,44 @@ Strava. What was found, 27 Oct:
|
||||||
ride starts with), and GPS's speed is ignored while the wheel sent within 5 s; a
|
ride starts with), and GPS's speed is ignored while the wheel sent within 5 s; a
|
||||||
ride without a circumference (no speed sensor) pauses by GPS as before. Not tried
|
ride without a circumference (no speed sensor) pauses by GPS as before. Not tried
|
||||||
with a real sensor or Health Connect.
|
with a real sensor or Health Connect.
|
||||||
|
- **D52 Music kept in the background, and smaller copies** (recommended, the
|
||||||
|
developer's; 29 Oct; step 14, what D47's 10b left open):
|
||||||
|
- **Downloads by a WorkManager job of Train's, in Kotlin, on the app's list** (14b;
|
||||||
|
over the job also reading the marked playlists, which writes part of the Jellyfin
|
||||||
|
client a second time; over the workmanager plugin running today's Dart in a
|
||||||
|
background engine, a new plugin and a whole engine for each run; over Android's
|
||||||
|
DownloadManager, which keeps the token in the system's download database): the
|
||||||
|
app, as it opens and resumes, reads the playlists as before, deletes what they no
|
||||||
|
longer hold and writes which tracks are missing; the job downloads those and works
|
||||||
|
out their tempo, a line each in the person's music folder, which the app takes in
|
||||||
|
as it opens and, while open, as each line comes. A playlist changed on the server
|
||||||
|
while the app is closed is noticed as it opens. The job does every download, the
|
||||||
|
open app's too, so that one code path runs and two never fetch the same track.
|
||||||
|
- **The token in one store of Train's, in Kotlin** (14a; over a second copy beside
|
||||||
|
flutter_secure_storage's, two to keep in step, and over the job reading that
|
||||||
|
plugin's own files, whose format has changed between versions): a file per person
|
||||||
|
in the no-backup folder, sealed with AES-GCM under a key in Android's Keystore
|
||||||
|
that can't be exported; the app reads it over the music channel; a token kept
|
||||||
|
before is moved over once and deleted from the keyring.
|
||||||
|
- **When**: every 6 hours and once whenever the list changes, on an unmetered network,
|
||||||
|
with the battery and the storage not low (over daily while charging, which leaves
|
||||||
|
a new playlist waiting overnight, and over every 12 hours).
|
||||||
|
- **Smaller copies** (14c): the music screen chooses *As they are* or *Smaller*, the
|
||||||
|
default *Smaller*: files above 256 kbps (FLAC and other lossless) are transcoded by
|
||||||
|
Jellyfin to Opus at 128 kbps in WebM, which Android's extractor and decoder read
|
||||||
|
on every version Train runs on; MP3s and AACs come as they are (over *As they are*
|
||||||
|
by default, and over always transcoding, which loses an MP3's quality twice and
|
||||||
|
has the server's processor do every track). Switching downloads the tracks again;
|
||||||
|
a file is kept until its new copy is whole, its tempo kept.
|
||||||
|
- Built: 14a. `JellyfinTokens.kt` keeps each person's token in the no-backup
|
||||||
|
folder's `jellyfin/`, a file named by their ID: a version byte, the IV the
|
||||||
|
Keystore makes and the token sealed with AES-GCM under `train_jellyfin`, a
|
||||||
|
256-bit key of Android's Keystore made once, the person's ID its associated data;
|
||||||
|
a file that doesn't open is deleted and counts as none, so the person signs in
|
||||||
|
again. Tested on the JVM with a software key. `AndroidJellyfinTokenStore` asks it
|
||||||
|
over the music channel and moves a token the keyring holds under
|
||||||
|
`train_jellyfin:` over as it is first read; keeping one, and forgetting others',
|
||||||
|
clears the keyring's too.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue