improved android client, feature parity with desktop

This commit is contained in:
nvrl committed 2026-08-09 16:40:37 +02:00
1 parent 352fdf1933
commit f1b3ad9637
56 files changed
+9721 -1793

No files matched your search

Generated
+8 -7
View File
@@ -4262,7 +4262,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-android" name = "pstr-android"
version = "0.2.0" version = "0.3.0"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"jni 0.21.1", "jni 0.21.1",
@@ -4281,7 +4281,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-app" name = "pstr-app"
version = "0.2.0" version = "0.3.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"eframe", "eframe",
@@ -4303,7 +4303,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-cli" name = "pstr-cli"
version = "0.2.0" version = "0.3.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -4321,7 +4321,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-core" name = "pstr-core"
version = "0.2.0" version = "0.3.0"
dependencies = [ dependencies = [
"directories", "directories",
"futures", "futures",
@@ -4339,7 +4339,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-meta" name = "pstr-meta"
version = "0.2.0" version = "0.3.0"
dependencies = [ dependencies = [
"pstr-core", "pstr-core",
"reqwest", "reqwest",
@@ -4352,12 +4352,13 @@ dependencies = [
[[package]] [[package]]
name = "pstr-player" name = "pstr-player"
version = "0.2.0" version = "0.3.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"libloading", "libloading",
"libmpv2", "libmpv2",
"libmpv2-sys", "libmpv2-sys",
"pstr-core",
"pstr-stream", "pstr-stream",
"thiserror 2.0.19", "thiserror 2.0.19",
"tokio", "tokio",
@@ -4366,7 +4367,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-stream" name = "pstr-stream"
version = "0.2.0" version = "0.3.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"futures", "futures",
+1 -1
View File
@@ -11,7 +11,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.2.0" version = "0.3.0"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
repository = "https://github.com/narrrl/proton-stream" repository = "https://github.com/narrrl/proton-stream"
+29
View File
@@ -0,0 +1,29 @@
# ktlint configuration.
#
# The whole standard ruleset is off, and rules are enabled one at a time. That
# is deliberate: turning it all on reports ~540 findings on the existing tree,
# almost all of them line-wrapping preference, and a mechanical reformat of
# every file would bury the changes that matter in review. The rules kept here
# are the ones whose violation is a defect or a real maintenance cost rather
# than a matter of taste — starting with the unused imports that were the
# evidence no gate ran at all.
#
# Adding a rule is cheap. Do it with the reformat it implies in its own commit.
[*.{kt,kts}]
ktlint_standard = disabled
ktlint_standard_no-unused-imports = enabled
ktlint_standard_no-wildcard-imports = enabled
ktlint_standard_no-semi = enabled
ktlint_standard_no-trailing-spaces = enabled
ktlint_standard_no-consecutive-blank-lines = enabled
ktlint_standard_no-empty-file = enabled
ktlint_standard_final-newline = enabled
ktlint_standard_filename = enabled
ktlint_standard_no-line-break-before-assignment = enabled
ktlint_standard_no-multi-spaces = enabled
# Vendored from rustls-platform-verifier. Restyling it would turn the next sync
# with upstream into a merge conflict for no benefit.
[**/org/rustls/**.kt]
ktlint = disabled
+78 -2
View File
@@ -5,6 +5,7 @@ plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ktlint)
} }
val repositoryRoot = rootProject.projectDir.parentFile val repositoryRoot = rootProject.projectDir.parentFile
@@ -53,6 +54,7 @@ android {
targetSdk = 36 targetSdk = 36
versionCode = releaseVersionCode versionCode = releaseVersionCode
versionName = releaseVersionName versionName = releaseVersionName
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
ndk { ndk {
abiFilters += listOf("arm64-v8a", "x86_64") abiFilters += listOf("arm64-v8a", "x86_64")
@@ -103,7 +105,51 @@ android {
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
if (releaseStore != null) signingConfig = signingConfigs.getByName("release") if (releaseStore != null) signingConfig = signingConfigs.getByName("release")
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
// Applied to the shipping APK on purpose; the file explains the
// trade-off and what each keep is repairing.
"proguard-instrumentation.pro",
)
testProguardFiles("proguard-test-rules.pro")
}
}
// The minified path is what ships, and R8 breaks reflective UniFFI/JNA
// surfaces in ways no debug run can see. CI sets this to `release` and runs
// connectedAndroidTest against it; locally the default keeps builds fast.
testBuildType = providers.environmentVariable("ANDROID_TEST_BUILD_TYPE").orNull ?: "debug"
lint {
warningsAsErrors = true
abortOnError = true
checkDependencies = true
// The baseline records what was already here when the gate went in, so
// anything *new* fails. It is not a permanent exemption: every entry in
// it maps to an open bug in docs/BUGS.md (the SharedPreferences
// `commit()` calls are B20, the PiP one is B40), and the file should get
// shorter as those land. Regenerate with `gradlew updateLintBaseline`
// after a fix, and never to make an unrelated failure go away.
baseline = file("lint-baseline.xml")
// Generated UniFFI bindings are not ours to fix.
disable += setOf("UnusedIds")
// Dependency freshness is not a defect, and as errors these break the
// gate whenever someone else cuts a release. Upgrades are a deliberate
// act with their own verification; `gradlew lintDebug -Dlint...` or a
// dependency bot is the right place to surface them, not CI.
informational += setOf(
"GradleDependency",
"NewerVersionAvailable",
"AndroidGradlePluginVersion",
)
sarifReport = true
}
testOptions {
unitTests {
isIncludeAndroidResources = true
isReturnDefaultValues = true
} }
} }
@@ -209,7 +255,6 @@ dependencies {
implementation(libs.androidx.work.runtime) implementation(libs.androidx.work.runtime)
implementation(libs.jna) { artifact { type = "aar" } } implementation(libs.jna) { artifact { type = "aar" } }
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)
@@ -219,5 +264,36 @@ dependencies {
implementation(libs.androidx.compose.material3.adaptive) implementation(libs.androidx.compose.material3.adaptive)
implementation(libs.androidx.compose.material3.navigation.suite) implementation(libs.androidx.compose.material3.navigation.suite)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core)
testImplementation(libs.androidx.work.testing)
androidTestImplementation(libs.androidx.test.junit)
androidTestImplementation(libs.androidx.test.core)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.work.testing)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.test.manifest)
} }
ktlint {
version.set("1.5.0")
android.set(true)
ignoreFailures.set(false)
filter {
// UniFFI writes these; they are regenerated on every build.
exclude { it.file.path.contains("${File.separator}generated${File.separator}") }
// Vendored from rustls-platform-verifier. Restyling it would make the
// next sync with upstream a merge conflict for no benefit.
exclude { it.file.path.contains("${File.separator}org${File.separator}rustls${File.separator}") }
}
}
// The generated UniFFI directory is a main source dir, so ktlint reads it even
// though the filter above drops every file in it. Without this, Gradle refuses
// the build for an undeclared dependency between the two tasks.
tasks.matching { it.name.startsWith("runKtlintCheckOver") || it.name.startsWith("runKtlintFormatOver") }
.configureEach { dependsOn(generateUniFfiBindings) }
+268
View File
@@ -0,0 +1,268 @@
<?xml version="1.0" encoding="UTF-8"?>
<issues format="6" by="lint 8.13.2" type="baseline" client="gradle" dependencies="true" name="AGP (8.13.2)" variant="all" version="8.13.2">
<issue
id="AndroidGradlePluginVersion"
message="A newer version of com.android.application than 8.13.2 is available: 9.3.1"
errorLine1="agp = &quot;8.13.2&quot;"
errorLine2=" ~~~~~~~~">
<location
file="../gradle/libs.versions.toml"
line="2"
column="7"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.compose:compose-bom than 2026.06.00 is available: 2026.06.01"
errorLine1="compose-bom = &quot;2026.06.00&quot;"
errorLine2=" ~~~~~~~~~~~~">
<location
file="../gradle/libs.versions.toml"
line="4"
column="15"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.activity:activity-compose than 1.11.0 is available: 1.13.0"
errorLine1="activity = &quot;1.11.0&quot;"
errorLine2=" ~~~~~~~~">
<location
file="../gradle/libs.versions.toml"
line="5"
column="12"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.lifecycle:lifecycle-runtime-ktx than 2.9.4 is available: 2.11.0"
errorLine1="lifecycle = &quot;2.9.4&quot;"
errorLine2=" ~~~~~~~">
<location
file="../gradle/libs.versions.toml"
line="6"
column="13"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.lifecycle:lifecycle-viewmodel-compose than 2.9.4 is available: 2.11.0"
errorLine1="lifecycle = &quot;2.9.4&quot;"
errorLine2=" ~~~~~~~">
<location
file="../gradle/libs.versions.toml"
line="6"
column="13"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.navigation:navigation-compose than 2.9.5 is available: 2.9.8"
errorLine1="navigation = &quot;2.9.5&quot;"
errorLine2=" ~~~~~~~">
<location
file="../gradle/libs.versions.toml"
line="7"
column="14"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.work:work-runtime-ktx than 2.10.5 is available: 2.11.2"
errorLine1="work = &quot;2.10.5&quot;"
errorLine2=" ~~~~~~~~">
<location
file="../gradle/libs.versions.toml"
line="8"
column="8"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.work:work-testing than 2.10.5 is available: 2.11.2"
errorLine1="work = &quot;2.10.5&quot;"
errorLine2=" ~~~~~~~~">
<location
file="../gradle/libs.versions.toml"
line="8"
column="8"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.compose.material3.adaptive:adaptive than 1.1.0 is available: 1.2.0"
errorLine1="adaptive = &quot;1.1.0&quot;"
errorLine2=" ~~~~~~~">
<location
file="../gradle/libs.versions.toml"
line="9"
column="12"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.compose.material3:material3-adaptive-navigation-suite than 1.1.0 is available: 1.4.0"
errorLine1="adaptive = &quot;1.1.0&quot;"
errorLine2=" ~~~~~~~">
<location
file="../gradle/libs.versions.toml"
line="9"
column="12"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.core:core-ktx than 1.17.0 is available: 1.19.0"
errorLine1="androidx-core = { module = &quot;androidx.core:core-ktx&quot;, version = &quot;1.17.0&quot; }"
errorLine2=" ~~~~~~~~">
<location
file="../gradle/libs.versions.toml"
line="19"
column="64"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of org.jetbrains.kotlin.android than 2.2.21 is available: 2.4.10"
errorLine1="kotlin = &quot;2.2.21&quot;"
errorLine2=" ~~~~~~~~">
<location
file="../gradle/libs.versions.toml"
line="3"
column="10"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of org.jetbrains.kotlin.plugin.compose than 2.2.21 is available: 2.4.10"
errorLine1="kotlin = &quot;2.2.21&quot;"
errorLine2=" ~~~~~~~~">
<location
file="../gradle/libs.versions.toml"
line="3"
column="10"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of net.java.dev.jna:jna than 5.17.0 is available: 5.19.1"
errorLine1="jna = &quot;5.17.0&quot;"
errorLine2=" ~~~~~~~~">
<location
file="../gradle/libs.versions.toml"
line="10"
column="7"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of org.robolectric:robolectric than 4.16 is available: 4.16.1"
errorLine1="robolectric = &quot;4.16&quot;"
errorLine2=" ~~~~~~">
<location
file="../gradle/libs.versions.toml"
line="12"
column="15"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of org.jlleitschuh.gradle.ktlint than 13.1.0 is available: 14.2.0"
errorLine1="ktlint-plugin = &quot;13.1.0&quot;"
errorLine2=" ~~~~~~~~">
<location
file="../gradle/libs.versions.toml"
line="16"
column="17"/>
</issue>
<issue
id="ObsoleteSdkInt"
message="Unnecessary; `SDK_INT` is always >= 31"
errorLine1=" if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/kotlin/org/rustls/platformverifier/CertificateVerifier.kt"
line="294"
column="13"/>
</issue>
<issue
id="AutoboxingStateCreation"
message="Prefer `mutableIntStateOf` instead of `mutableStateOf`"
errorLine1=" var remaining by remember(next?.linkId) { mutableStateOf(UP_NEXT_SECONDS) }"
errorLine2=" ~~~~~~~~~~~~~~">
<location
file="src/main/java/io/narl/protonstream/playback/PlayerScreen.kt"
line="707"
column="47"/>
</issue>
<issue
id="UseKtx"
message="Use the KTX extension function `SharedPreferences.edit` instead?"
errorLine1=" check(preferences.edit().putString(key, Base64.encodeToString(payload, Base64.NO_WRAP)).commit())"
errorLine2=" ~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/io/narl/protonstream/native/KeystoreSecretStore.kt"
line="41"
column="15"/>
</issue>
<issue
id="UseKtx"
message="Use the KTX extension function `SharedPreferences.edit` instead?"
errorLine1=" check(preferences.edit().remove(key).commit())"
errorLine2=" ~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/io/narl/protonstream/native/KeystoreSecretStore.kt"
line="64"
column="15"/>
</issue>
<issue
id="UseKtx"
message="Use the KTX extension function `SharedPreferences.edit` instead?"
errorLine1=" set(value) { preferences.edit().putBoolean(KEY_WIFI_ONLY, value).apply() }"
errorLine2=" ~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/io/narl/protonstream/settings/SettingsStore.kt"
line="10"
column="22"/>
</issue>
<issue
id="UseKtx"
message="Use the KTX extension function `SharedPreferences.edit` instead?"
errorLine1=" set(value) { preferences.edit().putBoolean(KEY_BACKGROUND_AUDIO, value).apply() }"
errorLine2=" ~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/io/narl/protonstream/settings/SettingsStore.kt"
line="14"
column="22"/>
</issue>
<issue
id="UseKtx"
message="Use the KTX extension function `SharedPreferences.edit` instead?"
errorLine1=" set(value) { preferences.edit().putBoolean(KEY_AUTO_SKIP, value).apply() }"
errorLine2=" ~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/io/narl/protonstream/settings/SettingsStore.kt"
line="25"
column="22"/>
</issue>
<issue
id="UseKtx"
message="Use the KTX extension function `SharedPreferences.edit` instead?"
errorLine1=" set(value) { preferences.edit().putBoolean(KEY_AUTOPLAY, value).apply() }"
errorLine2=" ~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/io/narl/protonstream/settings/SettingsStore.kt"
line="30"
column="22"/>
</issue>
</issues>
+98
View File
@@ -0,0 +1,98 @@
# Keeps that exist so the instrumentation suite can run against the *release*
# build. They are applied to the shipping APK, which is a real trade-off worth
# stating plainly: the tested artifact is not byte-identical to an artifact
# built without them.
#
# It is the better side of the trade. The alternative is to run instrumentation
# against `debug` only, which leaves R8 — the step that has already broken this
# app twice, in ways no debug run can reproduce — entirely uncovered. What is
# kept here is a few kilobytes of framework plumbing containing no application
# logic and revealing nothing about the app.
#
# Why these keeps are needed at all: the instrumentation APK is minified in a
# separate R8 run that consumes the app's mapping file, and links against the
# app's classes at runtime. That arrangement fails in two ways.
#
# 1. The mapping file carries renames but not R8's parameter-permutation
# optimisation, so a permuted method resolves to the wrong descriptor from
# the test APK.
# 2. A class that only the test APK reaches is unreachable from the app's call
# graph, so R8 removes it from the app APK — and the test APK does not
# bundle it, because it is supposed to come from the app.
# The Kotlin runtime is where both failures land, because androidx.test is
# itself Kotlin and shares the app's copy of the stdlib. Keeping it wholesale
# rather than class by class is deliberate: each individual keep only moves the
# crash to the next facade the runner happens to touch, which is a list nobody
# can enumerate ahead of time. Three were hit before this rule replaced them:
#
# NoSuchMethodError: No static method f(Ljava/lang/Object;Ljava/lang/String;)V
# in class Lo6/k; (o6.k = kotlin.jvm.internal.Intrinsics,
# whose parameters R8 had permuted —
# every Kotlin class calls it on entry,
# so nothing ran at all)
# NoClassDefFoundError: Lkotlin/LazyKt; (androidx.test.platform.io.TestDirCalculator)
#
# Cost is roughly a megabyte of dex against a ~100 MB APK that is almost
# entirely native libraries.
-keep class kotlin.** { *; }
# `AndroidJUnitRunner.onCreate` traces its own startup. Nothing in the app
# reaches androidx.tracing, so R8 dropped it and the runner could not start:
#
# java.lang.NoClassDefFoundError: Failed resolution of: Landroidx/tracing/Trace;
# at androidx.test.runner.AndroidJUnitRunner.onCreate(AndroidJUnitRunner.java:307)
-keep class androidx.tracing.** { *; }
# A third shape of the same problem, and the one worth understanding: the app
# only ever touches WorkManager through its implementation, so R8 vertically
# merged the abstract `androidx.work.WorkManager` into `WorkManagerImpl` and the
# abstract type stopped existing. It is absent from mapping.txt entirely rather
# than renamed. The download tests declare fields of the public types, so JUnit's
# field scan could not even construct the test class:
#
# java.lang.NoClassDefFoundError: Failed resolution of: Landroidx/work/WorkManager;
# at org.junit.runners.model.TestClass.getSortedDeclaredFields(TestClass.java:77)
#
# Kept package-wide because the same merge can happen to any of the public API
# types the tests name (WorkInfo, Data, Constraints), and finding out one crash
# at a time costs a four-minute device round trip each.
-keep class androidx.work.** { *; }
# Everything above is framework plumbing. The last group is app code, and it is
# the one keep in this file that costs real fidelity: R8 no longer optimises
# these classes in the shipping build, so the suite no longer proves R8 handles
# them correctly. It is a narrow loss — the surfaces R8 has actually
# broken here are the FFI ones, and those are kept for functional reasons in
# proguard-rules.pro and stay under test.
#
# The trigger was `DownloadStateStore.clear()`. It is not dead code — AppViewModel
# calls it — but it has exactly one call site, so R8 inlined it and dropped the
# method. Only a caller that resolves it by name, which is to say a test, notices:
#
# java.lang.NoSuchMethodError: No virtual method clear()V
# in class Lio/narl/protonstream/download/c;
#
# Package-wide rather than class-by-class: Kotlin's top-level functions live in
# a synthesised `...Kt` facade that no source file names, so keeping the classes
# a test imports still leaves it missing `DownloadCoordinatorKt`.
-keep class io.narl.protonstream.download.** { *; }
# Same inlining, one layer out: the `wifiOnly` setter behind the download
# constraint.
# java.lang.NoSuchMethodError: No virtual method setWifiOnly(Z)V in class Li5/a;
-keep class io.narl.protonstream.settings.SettingsStore { *; }
# The live cases drive the player directly, so the same inlining applies to the
# playback classes they name.
-keep class io.narl.protonstream.playback.** { *; }
# `runBlocking` — the live cases drive suspending bridge calls from a JUnit
# thread. The app itself never blocks a thread on a coroutine, so R8 drops the
# facade that holds it:
#
# java.lang.NoClassDefFoundError: Failed resolution of: Lkotlinx/coroutines/BuildersKt;
#
# Kept narrowly rather than as `kotlinx.coroutines.**`: that library is large and
# on the app's hot paths, and pinning all of it would cost real optimisation.
-keep class kotlinx.coroutines.BuildersKt** { *; }
+16
View File
@@ -7,3 +7,19 @@
-keepclasseswithmembernames class * { -keepclasseswithmembernames class * {
native <methods>; native <methods>;
} }
# UniFFI records are Kotlin data classes whose generic signatures and
# annotations survive into the runtime; stripping them changes how the bindings
# lift and lower structured types.
-keepattributes Signature,*Annotation*,InnerClasses,EnclosingMethod
# JNA dispatches into callbacks reflectively by method name.
-keepclassmembers class * implements com.sun.jna.Callback {
<methods>;
}
# The bridge calls back into these from Rust. Nothing in the Kotlin call graph
# reaches their overrides, so R8 is free to rename or drop them.
-keep class io.narl.protonstream.native.KeystoreSecretStore { *; }
-keep class * implements uniffi.pstr_android.AndroidSecretStore { *; }
-keep class * implements uniffi.pstr_android.DownloadObserver { *; }
+11
View File
@@ -0,0 +1,11 @@
# Applied only to the instrumentation APK when androidTest runs against a
# minified build (ANDROID_TEST_BUILD_TYPE=release). The tests exist to prove the
# *app's* R8 configuration is right, so nothing here may relax the app's rules —
# it only keeps the test harness itself intact.
-dontobfuscate
-keep class io.narl.protonstream.** { *; }
-keep class androidx.test.** { *; }
-dontwarn androidx.test.**
-keepclasseswithmembers class * {
@org.junit.Test <methods>;
}
@@ -0,0 +1,129 @@
package io.narl.protonstream.download
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.work.Configuration
import androidx.work.NetworkType
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.testing.SynchronousExecutor
import androidx.work.testing.WorkManagerTestInitHelper
import io.narl.protonstream.settings.SettingsStore
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
/**
* What actually reaches WorkManager. The unit tests cover the pure helpers; this
* covers the enqueue itself — the input data (which is persisted unencrypted, so
* it must carry no secret), the constraints, and the unique-work identity that
* pause and resume depend on.
*/
@RunWith(AndroidJUnit4::class)
class DownloadCoordinatorInstrumentedTest {
private lateinit var context: Context
private lateinit var workManager: WorkManager
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
WorkManagerTestInitHelper.initializeTestWorkManager(
context,
Configuration.Builder().setExecutor(SynchronousExecutor()).build(),
)
workManager = WorkManager.getInstance(context)
workManager.cancelAllWork().result.get()
DownloadStateStore(context).clear()
}
private fun download(linkId: String = "link-1", shareId: String = "share-1") = RetainedDownload(
shareId = shareId,
volumeId = "volume-1",
linkId = linkId,
label = "S01E01.mkv",
)
private fun infoFor(download: RetainedDownload): WorkInfo? =
workManager.getWorkInfosForUniqueWork(
DownloadCoordinator.workName(download.shareId, download.linkId),
).get().firstOrNull()
@Test
fun resumingEnqueuesUniqueWorkAndRetainsAQueuedRecord() {
val download = download()
DownloadCoordinator.resume(context, download)
assertNotNull(infoFor(download))
assertEquals(
RetainedDownload.STATUS_QUEUED,
DownloadStateStore(context).get("share-1", "link-1")?.status,
)
}
/**
* Tags are persisted in `androidx.work.workdb` unencrypted and are what the
* UI queries and what cancel-by-share matches on. Pinning the exact set is
* what makes an accidental addition — a fragment, a URL — visible in review.
*/
@Test
fun theWorkCarriesExactlyTheExpectedTags() {
val download = download()
DownloadCoordinator.resume(context, download)
val tags = infoFor(download)?.tags.orEmpty()
assertTrue(DownloadCoordinator.TAG in tags)
assertTrue(DownloadCoordinator.shareTag("share-1") in tags)
assertTrue("${DownloadCoordinator.EPISODE_TAG}S01E01.mkv" in tags)
// The remaining tag is WorkManager's own worker class name.
assertEquals(4, tags.size)
}
@Test
fun theWifiOnlySettingReachesTheConstraint() {
// Documents B29: the constraint is read at enqueue time, so work already
// queued keeps whatever was set when it was queued. When B29 is fixed,
// this test grows a case for flipping the toggle after enqueue.
SettingsStore(context).wifiOnly = true
assertEquals(NetworkType.UNMETERED, requiredNetworkType(SettingsStore(context).wifiOnly))
SettingsStore(context).wifiOnly = false
assertEquals(NetworkType.CONNECTED, requiredNetworkType(SettingsStore(context).wifiOnly))
}
@Test
fun pauseCancelsTheWorkAndRecordsThePausedStatus() {
val download = download()
DownloadCoordinator.resume(context, download)
DownloadCoordinator.pause(context, download)
assertTrue(infoFor(download)?.state?.isFinished ?: false)
assertEquals(
RetainedDownload.STATUS_PAUSED,
DownloadStateStore(context).get("share-1", "link-1")?.status,
)
}
@Test
fun cancellingOneShareLeavesAnotherSharesWorkAlone() {
val first = download(linkId = "a", shareId = "share-1")
val second = download(linkId = "b", shareId = "share-2")
DownloadCoordinator.resume(context, first)
DownloadCoordinator.resume(context, second)
workManager.cancelAllWorkByTag(DownloadCoordinator.shareTag("share-1")).result.get()
assertTrue(infoFor(first)?.state?.isFinished ?: false)
assertEquals(WorkInfo.State.ENQUEUED, infoFor(second)?.state)
}
@Test
fun twoEpisodesOfOneShareEnqueueSeparately() {
DownloadCoordinator.resume(context, download(linkId = "a"))
DownloadCoordinator.resume(context, download(linkId = "b"))
assertEquals(2, workManager.getWorkInfosByTag(DownloadCoordinator.TAG).get().size)
}
}
@@ -0,0 +1,274 @@
package io.narl.protonstream.live
import android.graphics.SurfaceTexture
import android.view.Surface
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import io.narl.protonstream.playback.NativeMpvHost
import java.io.File
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.BeforeClass
import org.junit.Test
import org.junit.runner.RunWith
import uniffi.pstr_android.AndroidEngine
import uniffi.pstr_android.AndroidPaths
import uniffi.pstr_android.AndroidSecretStore
import uniffi.pstr_android.DownloadObserver
import uniffi.pstr_android.EpisodeRecord
/**
* The cases `docs/ANDROID.md` requires that cannot be answered without a real
* share: that a link resolves into a playable catalog, that libmpv actually
* decodes the decrypted stream, that a cancelled download leaves a resumable
* `.part`, and that a resume position survives a restart.
*
* The link is an instrumentation argument rather than a constant or a file, so
* the secret never enters the repository:
*
* ```
* -Pandroid.testInstrumentationRunnerArguments.pstr.shareUrl=...
* ```
*
* Without it every test here is skipped by assumption, and
* `scripts/android-acceptance.sh` reports the matrix as incomplete.
*
* Read-only against the share by construction: nothing here uploads, renames or
* deletes anything remote. It writes only to this test's own temporary
* directories.
*/
@RunWith(AndroidJUnit4::class)
class LiveShareTest {
@Test
fun theShareResolvesIntoAPlayableCatalog() {
val episodes = episodes()
assertTrue("the crawl found no playable file in the share", episodes.isNotEmpty())
// A file with no size cannot be streamed: the block layer needs the
// length to place block boundaries at all.
val sized = episodes.filter { (it.size ?: 0uL) > 0uL }
assertTrue("no episode in the share reported a size", sized.isNotEmpty())
}
@Test
fun openingAnEpisodeYieldsASeekableRevision() {
val episode = playable()
val stream = runBlocking {
engine.openStream(episode.shareId, episode.volumeId, episode.linkId)
}
try {
assertTrue("the opened revision has no length", stream.size() > 0uL)
// Without a revision id the block cache cannot tell one revision of
// a file from another, and a stale block would be served as fresh.
assertTrue("the opened revision has no id", stream.revisionId().isNotBlank())
} finally {
runBlocking { engine.releaseStream(episode.shareId, episode.volumeId, episode.linkId) }
}
}
/**
* The whole pipeline in one assertion: Proton block storage, decryption,
* the Rust stream C ABI, the JNI adapter and libmpv's demuxer. If the
* position advances, real decrypted bytes reached the decoder.
*/
@Test
fun libmpvDecodesTheDecryptedStream() {
val episode = playable()
val texture = SurfaceTexture(0).apply { setDefaultBufferSize(1280, 720) }
val surface = Surface(texture)
val host = NativeMpvHost()
val key = "${episode.shareId}:${episode.linkId}"
try {
host.attachSurface(surface)
val stream = runBlocking {
engine.openStream(episode.shareId, episode.volumeId, episode.linkId)
}
runBlocking { host.play(key, stream.nativeHandle(), stream.size()) }
host.setMuted(true)
host.setPaused(false)
val playing = waitFor(PLAYBACK_TIMEOUT_MS) { host.state.value.position > 0.0 }
assertTrue(
"libmpv never advanced past zero: ${host.state.value}",
playing,
)
// One host serves every episode in turn, so a reading that does not
// name this one would resume the wrong file.
assertEquals(key, host.state.value.media)
assertTrue("libmpv reported no duration", host.state.value.duration > 0.0)
} finally {
host.stop()
host.detachSurface()
host.close()
surface.release()
texture.release()
runBlocking { engine.releaseStream(episode.shareId, episode.volumeId, episode.linkId) }
}
}
/**
* Cancellation is polled between whole Proton blocks precisely so the bytes
* already written stay usable. This checks the retained `.part` is counted
* as partial rather than as a finished file, and that resuming continues
* from it instead of starting over.
*/
@Test
fun aCancelledDownloadLeavesAResumablePartFile() {
val episode = playable()
val firstRun = CancelAfter(bytes = RESUME_THRESHOLD_BYTES)
runCatching {
runBlocking {
engine.downloadEpisode(episode.shareId, episode.volumeId, episode.linkId, firstRun)
}
}
assumeTrue(
"the episode finished before cancellation could be observed; it is too small for this case",
!firstRun.completed,
)
assertTrue("the download reported no progress at all", firstRun.observed > 0L)
val retained = engine.storageUsage()
assertTrue("a cancelled download retained no bytes", retained.partialBytes > 0uL)
assertEquals("a cancelled download counted as a finished file", 0uL, retained.offlineCount)
// Resuming must pick up where the .part ended rather than at zero.
val secondRun = CancelAfter(bytes = retained.partialBytes.toLong() + RESUME_THRESHOLD_BYTES)
runCatching {
runBlocking {
engine.downloadEpisode(episode.shareId, episode.volumeId, episode.linkId, secondRun)
}
}
assertTrue(
"resuming restarted from zero: first reading was ${secondRun.firstReading}, " +
"but ${retained.partialBytes} bytes were already retained",
secondRun.firstReading >= retained.partialBytes.toLong(),
)
runBlocking { engine.removeOfflineEpisode(episode.shareId, episode.linkId) }
assertEquals(
"removing the episode left partial bytes behind",
0uL,
engine.storageUsage().partialBytes,
)
}
/** What the viewer sees as "continue watching" after the app is killed. */
@Test
fun aResumePositionSurvivesReopeningTheCatalog() {
val episode = playable()
engine.saveWatchState(episode.shareId, episode.linkId, RESUME_POSITION, null, false)
val reopened = AndroidEngine(paths, secrets)
val state = reopened.watchState(episode.shareId, episode.linkId)
assertNotNull("the resume position did not survive a reopen", state)
assertEquals(RESUME_POSITION, state!!.positionSecs, 0.001)
assertEquals(false, state.watched)
// The catalog view is what the UI reads, so it has to agree.
val listed = reopened.library(null)
.flatMap { it.seasons }
.flatMap { it.episodes }
.first { it.linkId == episode.linkId }
assertEquals(RESUME_POSITION, listed.resumeAt ?: -1.0, 0.001)
}
/** Cancels once the download has written enough to prove a resume. */
private class CancelAfter(private val bytes: Long) : DownloadObserver {
private val cancelled = AtomicBoolean(false)
private val progress = AtomicLong(0)
private val first = AtomicLong(-1)
val observed: Long get() = progress.get()
val firstReading: Long get() = first.get()
val completed: Boolean get() = !cancelled.get()
override fun onProgress(downloaded: ULong, total: ULong) {
first.compareAndSet(-1, downloaded.toLong())
progress.set(downloaded.toLong())
if (downloaded.toLong() >= bytes) cancelled.set(true)
}
override fun isCancelled(): Boolean = cancelled.get()
}
private fun episodes(): List<EpisodeRecord> =
engine.library(null).flatMap { it.seasons }.flatMap { it.episodes }
private fun playable(): EpisodeRecord =
episodes().firstOrNull { (it.size ?: 0uL) > 0uL }
?: throw AssertionError("the share has no sized file to play")
private fun waitFor(timeoutMs: Long, condition: () -> Boolean): Boolean {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
if (condition()) return true
Thread.sleep(200)
}
return false
}
/** Stands in for Keystore, so a bridge failure cannot be blamed on crypto. */
private class MemorySecretStore : AndroidSecretStore {
private val entries = ConcurrentHashMap<String, String>()
override fun set(key: String, value: String) {
entries[key] = value
}
override fun get(key: String): String? = entries[key]
override fun delete(key: String) {
entries.remove(key)
}
}
companion object {
private const val SHARE_URL_ARGUMENT = "pstr.shareUrl"
private const val SHARE_PASSWORD_ARGUMENT = "pstr.sharePassword"
/** Long enough for a cold connection, a crawl and a first block read. */
private const val PLAYBACK_TIMEOUT_MS = 60_000L
/** Enough written bytes to prove a resume, small enough to stay quick. */
private const val RESUME_THRESHOLD_BYTES = 4L * 1024 * 1024
private const val RESUME_POSITION = 137.5
private lateinit var paths: AndroidPaths
private lateinit var secrets: AndroidSecretStore
private lateinit var engine: AndroidEngine
@BeforeClass
@JvmStatic
fun openTheShare() {
System.loadLibrary("pstr_android")
val arguments = InstrumentationRegistry.getArguments()
val url = arguments.getString(SHARE_URL_ARGUMENT)
assumeTrue(
"set -Pandroid.testInstrumentationRunnerArguments.$SHARE_URL_ARGUMENT to run the live cases",
!url.isNullOrBlank(),
)
val password = arguments.getString(SHARE_PASSWORD_ARGUMENT)?.takeIf { it.isNotBlank() }
val root = File.createTempFile("pstr-live", "").let {
it.delete()
it.mkdirs()
it
}
paths = AndroidPaths(
File(root, "config").absolutePath,
File(root, "data").absolutePath,
File(root, "cache").absolutePath,
)
secrets = MemorySecretStore()
engine = AndroidEngine(paths, secrets)
engine.addShare("acceptance", url!!, password)
runBlocking { engine.crawl(null) }
}
}
}
@@ -0,0 +1,155 @@
package io.narl.protonstream.native
import android.content.Context
import android.util.Base64
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import java.security.KeyStore
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import uniffi.pstr_android.BridgeException
/**
* Runs on a device because Android Keystore has no host implementation — which
* is why nothing pinned this before. The share fragment and any custom link
* password are the only user secrets the app holds, and losing the key makes
* every share unopenable, so the failure modes matter as much as the happy path.
*/
@RunWith(AndroidJUnit4::class)
class KeystoreSecretStoreTest {
private lateinit var context: Context
private lateinit var store: KeystoreSecretStore
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
clearPreferences()
store = KeystoreSecretStore(context)
}
@After
fun tearDown() {
clearPreferences()
}
@Test
fun aSecretSurvivesARoundTrip() {
store.set("share-1", "correct horse battery staple")
assertEquals("correct horse battery staple", store.get("share-1"))
}
@Test
fun anAbsentSecretIsNullRatherThanEmpty() {
assertNull(store.get("never-written"))
}
@Test
fun deleteRemovesTheSecret() {
store.set("share-1", "value")
store.delete("share-1")
assertNull(store.get("share-1"))
}
@Test
fun aUnicodeSecretIsNotMangled() {
val value = "パスワード・🎬・ü"
store.set("share-1", value)
assertEquals(value, store.get("share-1"))
}
/**
* GCM is catastrophic under IV reuse: two secrets encrypted with the same
* key and IV leak the XOR of their plaintexts. `Cipher.init(ENCRYPT_MODE)`
* is what generates a fresh one, so this pins that the code never pins it.
*/
@Test
fun everyWriteUsesAFreshInitialisationVector() {
store.set("share-1", "same plaintext")
val first = rawPayload("share-1")
store.set("share-1", "same plaintext")
val second = rawPayload("share-1")
val firstIv = first.copyOfRange(0, IV_SIZE)
val secondIv = second.copyOfRange(0, IV_SIZE)
assertNotEquals(Base64.encodeToString(firstIv, Base64.NO_WRAP), Base64.encodeToString(secondIv, Base64.NO_WRAP))
}
@Test
fun theStoredFormIsNotThePlaintext() {
store.set("share-1", "correct horse battery staple")
val stored = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.getString("share-1", null)
assertTrue(stored != null && !stored.contains("correct horse"))
}
/**
* A tampered payload must not decrypt, and must fail as a [BridgeException].
* An `AEADBadTagException` crossing the FFI has no mapping on the Rust side,
* and an unmapped callback exception panics the calling thread (B18).
*/
@Test
fun aTamperedPayloadFailsToDecrypt() {
store.set("share-1", "value")
val payload = rawPayload("share-1")
payload[payload.size - 1] = (payload[payload.size - 1].toInt() xor 0x01).toByte()
writeRawPayload("share-1", payload)
assertThrows(BridgeException::class.java) { store.get("share-1") }
}
@Test
fun aTruncatedPayloadIsRejectedBeforeDecryption() {
store.set("share-1", "value")
writeRawPayload("share-1", rawPayload("share-1").copyOfRange(0, IV_SIZE))
assertThrows(BridgeException::class.java) { store.get("share-1") }
}
/**
* The device-side half of B18: this is what a lockscreen change or a device
* restore does to the key. It must fail the read rather than the process,
* and the store must still accept a re-entered secret afterwards — which is
* what the shares screen's "Re-enter link" depends on.
*/
@Test
fun aLostKeyFailsTheReadRatherThanTheProcess() {
store.set("share-1", "value")
KeyStore.getInstance("AndroidKeyStore").apply { load(null) }.deleteEntry(KEY_ALIAS)
assertThrows(BridgeException::class.java) { store.get("share-1") }
// The store must still be usable for a re-entered secret.
store.set("share-1", "re-entered")
assertEquals("re-entered", store.get("share-1"))
}
private fun rawPayload(key: String): ByteArray {
val encoded = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.getString(key, null) ?: error("no payload for $key")
return Base64.decode(encoded, Base64.NO_WRAP)
}
private fun writeRawPayload(key: String, payload: ByteArray) {
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.edit()
.putString(key, Base64.encodeToString(payload, Base64.NO_WRAP))
.commit()
}
private fun clearPreferences() {
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit().clear().commit()
}
private companion object {
const val PREFERENCES = "proton_stream_secrets"
const val KEY_ALIAS = "proton-stream-share-secrets-v1"
const val IV_SIZE = 12
}
}
@@ -0,0 +1,117 @@
package io.narl.protonstream.native
import androidx.test.ext.junit.runners.AndroidJUnit4
import java.io.File
import java.util.concurrent.ConcurrentHashMap
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.BeforeClass
import org.junit.Test
import org.junit.runner.RunWith
import uniffi.pstr_android.AndroidEngine
import uniffi.pstr_android.AndroidPaths
import uniffi.pstr_android.AndroidSecretStore
import uniffi.pstr_android.chapterPlan
import uniffi.pstr_android.skipOffer
/**
* The first thing that crosses the UniFFI boundary on a real device. It is
* deliberately offline: it proves the ABI, the generated bindings, the callback
* interface and the SQLite open all work, without needing a share.
*
* If this fails and the Rust workspace tests pass, the fault is in packaging —
* a missing ABI in `jniLibs`, a stale binding, or an R8 rule (run it with
* `ANDROID_TEST_BUILD_TYPE=release` to check the shipped configuration).
*/
@RunWith(AndroidJUnit4::class)
class UniFfiBoundaryTest {
/** Stands in for Keystore so a bridge failure cannot be blamed on crypto. */
private class MemorySecretStore : AndroidSecretStore {
val entries = ConcurrentHashMap<String, String>()
var reads = 0
private set
override fun set(key: String, value: String) {
entries[key] = value
}
override fun get(key: String): String? {
reads += 1
return entries[key]
}
override fun delete(key: String) {
entries.remove(key)
}
}
private fun engine(secrets: AndroidSecretStore = MemorySecretStore()): AndroidEngine {
val root = File.createTempFile("pstr-bridge", "").let {
it.delete()
it.mkdirs()
it
}
return AndroidEngine(
AndroidPaths(
File(root, "config").absolutePath,
File(root, "data").absolutePath,
File(root, "cache").absolutePath,
),
secrets,
)
}
@Test
fun theEngineOpensAgainstEmptyDirectories() {
val engine = engine()
assertTrue(engine.shares().isEmpty())
assertTrue(engine.library(null).isEmpty())
}
@Test
fun storageUsageReportsZeroOnAFreshInstall() {
val usage = engine().storageUsage()
assertNotNull(usage)
assertEquals(0UL, usage.offlineBytes)
assertEquals(0UL, usage.partialBytes)
assertEquals(0UL, usage.offlineCount)
}
@Test
fun metadataSettingsAreReadableAndDefaultToDisabled() {
// Enrichment is opt-in; a default of enabled would send titles to a
// third party before the viewer has agreed to it.
assertEquals(false, engine().metadataSettings().enabled)
}
@Test
fun aSecondEngineReopensTheSameCatalog() {
val secrets = MemorySecretStore()
engine(secrets).shares()
// Reopening is the process-restart path; a schema or lock fault shows up
// here rather than on a user's device.
assertTrue(engine(secrets).shares().isEmpty())
}
/**
* Chapter classification is shared Rust that desktop already pins
* (`docs/BUGS.md` B12). This runs the same code through the bindings, so a
* lowering fault in the record types cannot pass unnoticed.
*/
@Test
fun chapterPlanningCrossesTheBoundaryIntact() {
val plan = chapterPlan(emptyList(), 1560.0)
assertNotNull(plan)
assertNull(skipOffer(plan, 12.0))
}
companion object {
@BeforeClass
@JvmStatic
fun loadLibrary() {
System.loadLibrary("pstr_android")
}
}
}
+1 -1
View File
@@ -29,7 +29,7 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation" android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|uiMode|density|fontScale|locale|layoutDirection|keyboardHidden"
android:exported="true" android:exported="true"
android:launchMode="singleTask" android:launchMode="singleTask"
android:supportsPictureInPicture="true" android:supportsPictureInPicture="true"
+343 -24
View File
@@ -1,6 +1,7 @@
#include "pstr_mpv.h" #include "pstr_mpv.h"
#include <EGL/egl.h> #include <EGL/egl.h>
#include <android/log.h>
#include <android/native_window.h> #include <android/native_window.h>
#include <android/native_window_jni.h> #include <android/native_window_jni.h>
#include <jni.h> #include <jni.h>
@@ -86,16 +87,32 @@ int64_t stream_size(void *opaque) noexcept {
}); });
} }
/// mpv wants its request slot back — a seek, or a teardown.
///
/// The flag alone only stops the *next* read; the one already parked in a 4 MiB
/// block fetch has to be interrupted in Rust, or `mpv_terminate_destroy` waits
/// out the whole fetch while joining the demuxer thread.
void stream_cancel(void *opaque) noexcept { void stream_cancel(void *opaque) noexcept {
c_boundary([=] { c_boundary([=] {
if (auto *stream = static_cast<StreamCookie *>(opaque)) { if (auto *stream = static_cast<StreamCookie *>(opaque)) {
stream->cancelled.store(true, std::memory_order_relaxed); stream->cancelled.store(true, std::memory_order_relaxed);
pstr_android_stream_cancel(stream->handle);
} }
}); });
} }
/// Releases the Rust stream behind the file mpv has finished with.
///
/// Ownership sits here rather than in Kotlin because this is the one moment
/// that is *defined* to be after mpv's last read: `loadfile` is asynchronous, so
/// a Kotlin-side release issued when the next load returns can land while the
/// outgoing demuxer is still reading — and, if the release names the handle the
/// registry has already swapped, takes the incoming episode's stream with it.
void stream_close(void *opaque) noexcept { void stream_close(void *opaque) noexcept {
c_boundary([=] { delete static_cast<StreamCookie *>(opaque); }); c_boundary([=] {
std::unique_ptr<StreamCookie> cookie(static_cast<StreamCookie *>(opaque));
if (cookie) pstr_android_stream_release(cookie->handle);
});
} }
int stream_open(void *, char *uri, mpv_stream_cb_info *info) noexcept { int stream_open(void *, char *uri, mpv_stream_cb_info *info) noexcept {
@@ -130,6 +147,29 @@ void *resolve_gl(void *, const char *name) noexcept {
}); });
} }
/// Why the open file ended, mirroring `pstr_player::EndReason` so that the two
/// clients agree on what "finished" means. Only [Eof] is an episode the viewer
/// watched to the end; the rest are a stop, a shutdown or a failure, and none of
/// them may mark anything watched or advance to the next episode.
enum class EndReason : int {
None = 0,
Eof = 1,
Stopped = 2,
Quit = 3,
Failed = 4,
Other = 5,
};
EndReason end_reason_of(int reason) {
switch (reason) {
case MPV_END_FILE_REASON_EOF: return EndReason::Eof;
case MPV_END_FILE_REASON_STOP: return EndReason::Stopped;
case MPV_END_FILE_REASON_QUIT: return EndReason::Quit;
case MPV_END_FILE_REASON_ERROR: return EndReason::Failed;
default: return EndReason::Other;
}
}
struct PlaybackState { struct PlaybackState {
double position = 0.0; double position = 0.0;
double duration = 0.0; double duration = 0.0;
@@ -137,6 +177,30 @@ struct PlaybackState {
bool paused = true; bool paused = true;
bool muted = false; bool muted = false;
bool ended = false; bool ended = false;
EndReason end_reason = EndReason::None;
/// mpv has run out of buffered data and stopped to refill. Distinct from
/// [paused], which is the viewer's doing: a frozen picture that nothing
/// explains is indistinguishable from a hung app.
bool buffering = false;
/// How full the demuxer cache is while [buffering], 0–100.
double cache_percent = 0.0;
/// Between a seek being issued and playback actually resuming. The seek bar
/// has moved but the picture has not.
bool seeking = false;
/// True between `loadfile` being issued and mpv acknowledging it with
/// `START_FILE`. Everything mpv reports in that window still describes the
/// *outgoing* file — including the `END_FILE(STOP)` that stopping it emits —
/// and must not be attributed to the file being loaded.
bool loading = false;
/// The picture's display size, once mpv has decoded enough to know it.
/// Zero until then, and the aspect Picture-in-Picture is given.
double video_width = 0.0;
double video_height = 0.0;
/// Which loaded file this state describes. Bumped by every load, so a
/// reader can tell "episode two, second zero" from a leftover reading of
/// episode one — which is the difference between resuming an episode where
/// the viewer left it and resuming it where the *previous* one ended.
int64_t generation = 0;
}; };
class Player { class Player {
@@ -165,6 +229,13 @@ class Player {
observe("pause", 3, MPV_FORMAT_FLAG); observe("pause", 3, MPV_FORMAT_FLAG);
observe("volume", 4, MPV_FORMAT_DOUBLE); observe("volume", 4, MPV_FORMAT_DOUBLE);
observe("mute", 5, MPV_FORMAT_FLAG); observe("mute", 5, MPV_FORMAT_FLAG);
observe("video-params/dw", 6, MPV_FORMAT_INT64);
observe("video-params/dh", 7, MPV_FORMAT_INT64);
observe("paused-for-cache", 8, MPV_FORMAT_FLAG);
observe("cache-buffering-state", 9, MPV_FORMAT_INT64);
// Errors mpv reports about the file itself, rather than about the
// request that opened it. Without these a failed demux is silent.
mpv_request_log_messages(mpv_, "error");
events_ = std::thread([this] { event_loop(); }); events_ = std::thread([this] { event_loop(); });
renderer_ = std::thread([this] { render_loop(); }); renderer_ = std::thread([this] { render_loop(); });
} }
@@ -213,23 +284,60 @@ class Player {
bool load(uint64_t handle, double start, const std::string &audio, bool load(uint64_t handle, double start, const std::string &audio,
const std::string &subtitle, bool subtitles) { const std::string &subtitle, bool subtitles) {
if (!mpv_) return false; if (!mpv_) return false;
{ // No wait for a render context. It is created with the render thread and
std::unique_lock lock(render_mutex_); // needs no window, and a load that cannot get one plays audio anyway —
if (!render_cv_.wait_for(lock, std::chrono::seconds(5), [this] { return render_ != nullptr || !running_.load(); })) return false; // waiting for a surface here is what made background playback fail after
} // a five-second stall.
if (!audio.empty()) set_string("alang", audio); if (!audio.empty()) set_string("alang", audio);
if (!subtitle.empty()) set_string("slang", subtitle); if (!subtitle.empty()) set_string("slang", subtitle);
set_string("sid", subtitles ? "auto" : "no"); set_string("sid", subtitles ? "auto" : "no");
pending_start_.store(std::max(0.0, start)); pending_start_.store(std::max(0.0, start));
// Retire the outgoing file's position, duration and end flag *before*
// the new one loads. mpv only republishes them once it has demuxed
// enough of the stream, and until then every reader would otherwise see
// the last episode's clock under the new episode's name.
{
std::lock_guard lock(state_mutex_);
const double volume = state_.volume;
const bool muted = state_.muted;
// `pause` is a property of the core, not of the file: mpv carries it
// across `loadfile` unchanged and therefore never re-publishes it.
// Resetting it to the struct's default would leave every reader
// believing a playing file is paused for as long as it is open, and
// every "play" they then send is a no-op against a core that never
// paused — which is a play/pause button that does nothing.
const bool paused = state_.paused;
const int64_t generation = state_.generation + 1;
state_ = PlaybackState{};
state_.volume = volume;
state_.muted = muted;
state_.paused = paused;
state_.generation = generation;
state_.loading = true;
error_.clear();
}
const std::string url = "pstr://" + std::to_string(handle); const std::string url = "pstr://" + std::to_string(handle);
const char *command[] = {"loadfile", url.c_str(), nullptr}; const char *command[] = {"loadfile", url.c_str(), nullptr};
if (mpv_command(mpv_, command) < 0) return false; if (mpv_command(mpv_, command) < 0) {
// Nothing was issued, so no START_FILE will arrive to clear this.
std::lock_guard lock(state_mutex_);
state_.loading = false;
return false;
}
return true; return true;
} }
void pause(bool value) { set_flag("pause", value); } /// Written through rather than left to the observer: the transport is drawn
/// from this state, and a tap that only shows its effect on mpv's next
/// property event reads as a button that missed.
void pause(bool value) {
set_flag("pause", value);
std::lock_guard lock(state_mutex_);
state_.paused = value;
}
void seek(double seconds) { set_double("time-pos", std::max(0.0, seconds)); } void seek(double seconds) { set_double("time-pos", std::max(0.0, seconds)); }
void volume(double value) { set_double("volume", std::clamp(value, 0.0, 100.0)); } void volume(double value) { set_double("volume", std::clamp(value, 0.0, 100.0)); }
void speed(double value) { set_double("speed", std::clamp(value, 0.25, 4.0)); }
void mute(bool value) { set_flag("mute", value); } void mute(bool value) { set_flag("mute", value); }
void select_track(bool audio, int64_t id) { void select_track(bool audio, int64_t id) {
const char *property = audio ? "aid" : "sid"; const char *property = audio ? "aid" : "sid";
@@ -246,6 +354,12 @@ class Player {
return state_; return state_;
} }
/// The pending error, if any, consumed by reading it.
std::string take_error() {
std::lock_guard lock(state_mutex_);
return std::exchange(error_, std::string());
}
std::string tracks_json() const { std::string tracks_json() const {
if (!mpv_) return "[]"; if (!mpv_) return "[]";
int64_t count = 0; int64_t count = 0;
@@ -274,22 +388,115 @@ class Player {
return json + ']'; return json + ']';
} }
/// The file's chapters, read one sub-property at a time like the tracks.
/// What they *mean* is decided in Rust (`pstr_core::chapters`), so the
/// desktop and this client offer the same skips.
std::string chapters_json() const {
if (!mpv_) return "[]";
int64_t count = 0;
if (mpv_get_property(mpv_, "chapter-list/count", MPV_FORMAT_INT64, &count) < 0) return "[]";
std::string json = "[";
for (int64_t i = 0; i < count; ++i) {
const std::string base = "chapter-list/" + std::to_string(i) + "/";
char *title_raw = nullptr;
double start = 0.0;
mpv_get_property(mpv_, (base + "title").c_str(), MPV_FORMAT_STRING, &title_raw);
MpvString title(title_raw);
mpv_get_property(mpv_, (base + "time").c_str(), MPV_FORMAT_DOUBLE, &start);
if (i) json += ',';
json += "{\"index\":" + std::to_string(i) + ",\"title\":\"" + escape(title.get()) +
"\",\"start\":" + std::to_string(start) + '}';
}
return json + ']';
}
private: private:
struct MpvFree { struct MpvFree {
void operator()(char *value) const noexcept { if (value) mpv_free(value); } void operator()(char *value) const noexcept { if (value) mpv_free(value); }
}; };
using MpvString = std::unique_ptr<char, MpvFree>; using MpvString = std::unique_ptr<char, MpvFree>;
/// JSON-escape `text`, and re-encode it as Modified UTF-8.
///
/// The result of this ends up in `NewStringUTF`, which does not take UTF-8:
/// it takes Modified UTF-8, where a character outside the basic plane is a
/// surrogate *pair* of three-byte sequences rather than one four-byte
/// sequence. Handing it real UTF-8 renders an emoji in a track title as
/// mojibake, and aborts the process under CheckJNI.
///
/// Anything that is not well-formed UTF-8 is dropped rather than passed
/// through, for the same reason: a truncated sequence is what CheckJNI
/// rejects, and a track title is not worth a crash.
static std::string escape(const char *text) { static std::string escape(const char *text) {
std::string out; std::string out;
if (!text) return out; if (!text) return out;
for (const unsigned char c : std::string_view(text)) { const std::string_view input(text);
if (c == '"' || c == '\\') out += '\\'; for (size_t i = 0; i < input.size();) {
if (c >= 0x20) out += static_cast<char>(c); const auto lead = static_cast<unsigned char>(input[i]);
size_t length = 0;
uint32_t code = 0;
if (lead < 0x80) {
length = 1;
code = lead;
} else if ((lead & 0xE0) == 0xC0) {
length = 2;
code = lead & 0x1Fu;
} else if ((lead & 0xF0) == 0xE0) {
length = 3;
code = lead & 0x0Fu;
} else if ((lead & 0xF8) == 0xF0) {
length = 4;
code = lead & 0x07u;
} else {
++i; // A stray continuation or invalid lead byte.
continue;
}
if (i + length > input.size()) break;
bool valid = true;
for (size_t k = 1; k < length; ++k) {
const auto next = static_cast<unsigned char>(input[i + k]);
if ((next & 0xC0) != 0x80) {
valid = false;
break;
}
code = (code << 6) | (next & 0x3Fu);
}
if (!valid) {
++i;
continue;
}
i += length;
if (code < 0x20) continue; // Control characters, including NUL.
if (code == '"' || code == '\\') {
out += '\\';
out += static_cast<char>(code);
continue;
}
if (code < 0x80) {
out += static_cast<char>(code);
} else if (code < 0x800) {
out += static_cast<char>(0xC0 | (code >> 6));
out += static_cast<char>(0x80 | (code & 0x3F));
} else if (code < 0x10000) {
append_three(out, code);
} else {
// The surrogate pair Modified UTF-8 wants, each half written as
// its own three-byte sequence.
const uint32_t rest = code - 0x10000;
append_three(out, 0xD800 + (rest >> 10));
append_three(out, 0xDC00 + (rest & 0x3FF));
}
} }
return out; return out;
} }
static void append_three(std::string &out, uint32_t code) {
out += static_cast<char>(0xE0 | (code >> 12));
out += static_cast<char>(0x80 | ((code >> 6) & 0x3F));
out += static_cast<char>(0x80 | (code & 0x3F));
}
void option(const char *name, const char *value) { mpv_set_option_string(mpv_, name, value); } void option(const char *name, const char *value) { mpv_set_option_string(mpv_, name, value); }
void observe(const char *name, uint64_t id, mpv_format format) { mpv_observe_property(mpv_, id, name, format); } void observe(const char *name, uint64_t id, mpv_format format) { mpv_observe_property(mpv_, id, name, format); }
void set_string(const char *name, const std::string &value) { mpv_set_property_string(mpv_, name, value.c_str()); } void set_string(const char *name, const std::string &value) { mpv_set_property_string(mpv_, name, value.c_str()); }
@@ -306,25 +513,64 @@ class Player {
if (start > 0.0) set_double("time-pos", start); if (start > 0.0) set_double("time-pos", start);
} }
if (event->event_id == MPV_EVENT_END_FILE) { if (event->event_id == MPV_EVENT_END_FILE) {
const auto *end = static_cast<mpv_event_end_file *>(event->data);
std::lock_guard lock(state_mutex_); std::lock_guard lock(state_mutex_);
// The end of the file `loadfile` is replacing, not of the one it
// is loading. Reporting it would hand every reader an episode
// that ended before it started.
if (state_.loading) continue;
state_.ended = true; state_.ended = true;
state_.end_reason = end ? end_reason_of(end->reason) : EndReason::Other;
} else if (event->event_id == MPV_EVENT_START_FILE) { } else if (event->event_id == MPV_EVENT_START_FILE) {
std::lock_guard lock(state_mutex_); std::lock_guard lock(state_mutex_);
state_.loading = false;
state_.ended = false; state_.ended = false;
state_.end_reason = EndReason::None;
} else if (event->event_id == MPV_EVENT_SEEK) {
std::lock_guard lock(state_mutex_);
state_.seeking = true;
} else if (event->event_id == MPV_EVENT_PLAYBACK_RESTART) {
// The seek is *visibly* done here, not when it was issued.
std::lock_guard lock(state_mutex_);
state_.seeking = false;
} else if (event->event_id == MPV_EVENT_LOG_MESSAGE) {
record_log(*static_cast<mpv_event_log_message *>(event->data));
} else if (event->event_id == MPV_EVENT_PROPERTY_CHANGE) { } else if (event->event_id == MPV_EVENT_PROPERTY_CHANGE) {
update_property(*static_cast<mpv_event_property *>(event->data)); update_property(*static_cast<mpv_event_property *>(event->data));
} }
} }
} }
/// Keep the first error of the open file, not the last.
///
/// A failure cascades — one unreadable header produces a dozen follow-on
/// complaints — and the first line is the one that names the cause. Cleared
/// by [take_error], which is how the UI consumes it.
void record_log(const mpv_event_log_message &message) {
if (!message.text) return;
std::lock_guard lock(state_mutex_);
if (!error_.empty()) return;
error_.assign(message.text);
while (!error_.empty() && (error_.back() == '\n' || error_.back() == '\r')) error_.pop_back();
}
void update_property(const mpv_event_property &property) { void update_property(const mpv_event_property &property) {
if (!property.data) return; if (!property.data) return;
std::lock_guard lock(state_mutex_); std::lock_guard lock(state_mutex_);
if (!std::strcmp(property.name, "time-pos")) state_.position = *static_cast<double *>(property.data); // `volume`, `mute` and `pause` belong to the core and carry across a
else if (!std::strcmp(property.name, "duration")) state_.duration = *static_cast<double *>(property.data); // load; the rest describe the open file, and a reading of them that
else if (!std::strcmp(property.name, "volume")) state_.volume = *static_cast<double *>(property.data); // arrives mid-load is the outgoing episode's clock under the incoming
// episode's name.
if (!std::strcmp(property.name, "volume")) state_.volume = *static_cast<double *>(property.data);
else if (!std::strcmp(property.name, "pause")) state_.paused = *static_cast<int *>(property.data); else if (!std::strcmp(property.name, "pause")) state_.paused = *static_cast<int *>(property.data);
else if (!std::strcmp(property.name, "mute")) state_.muted = *static_cast<int *>(property.data); else if (!std::strcmp(property.name, "mute")) state_.muted = *static_cast<int *>(property.data);
else if (state_.loading) return;
else if (!std::strcmp(property.name, "time-pos")) state_.position = *static_cast<double *>(property.data);
else if (!std::strcmp(property.name, "duration")) state_.duration = *static_cast<double *>(property.data);
else if (!std::strcmp(property.name, "video-params/dw")) state_.video_width = static_cast<double>(*static_cast<int64_t *>(property.data));
else if (!std::strcmp(property.name, "video-params/dh")) state_.video_height = static_cast<double>(*static_cast<int64_t *>(property.data));
else if (!std::strcmp(property.name, "paused-for-cache")) state_.buffering = *static_cast<int *>(property.data);
else if (!std::strcmp(property.name, "cache-buffering-state")) state_.cache_percent = static_cast<double>(*static_cast<int64_t *>(property.data));
} }
static void render_update(void *opaque) noexcept { static void render_update(void *opaque) noexcept {
@@ -336,6 +582,15 @@ class Player {
}); });
} }
/// Owns EGL and the mpv render context for the life of the player.
///
/// Both are set up here rather than on the first surface attach, and neither
/// depends on there being a window: a pbuffer is enough to make a context
/// current, and `vo_libmpv` only needs *some* consumer. That is what makes
/// audio playback with no surface — the screen off, the app backgrounded —
/// structurally possible; before this, `load` waited five seconds for a
/// render context that only a visible SurfaceView could ever create, and
/// then failed.
void render_loop() { void render_loop() {
EGLDisplay display = EGL_NO_DISPLAY; EGLDisplay display = EGL_NO_DISPLAY;
EGLContext context = EGL_NO_CONTEXT; EGLContext context = EGL_NO_CONTEXT;
@@ -343,6 +598,7 @@ class Player {
EGLSurface pbuffer = EGL_NO_SURFACE; EGLSurface pbuffer = EGL_NO_SURFACE;
EGLConfig config = nullptr; EGLConfig config = nullptr;
ANativeWindow *window = nullptr; ANativeWindow *window = nullptr;
if (initialize_egl(display, context, config, pbuffer)) initialize_renderer();
while (running_.load()) { while (running_.load()) {
std::unique_lock lock(render_mutex_); std::unique_lock lock(render_mutex_);
render_cv_.wait(lock, [this] { return !running_.load() || surface_changed_ || frame_ready_.load(); }); render_cv_.wait(lock, [this] { return !running_.load() || surface_changed_ || frame_ready_.load(); });
@@ -353,18 +609,31 @@ class Player {
if (window) ANativeWindow_release(window); if (window) ANativeWindow_release(window);
window = pending_window_; window = pending_window_;
pending_window_ = nullptr; pending_window_ = nullptr;
if (window && display == EGL_NO_DISPLAY && !initialize_egl(display, context, config, pbuffer)) { if (window && display == EGL_NO_DISPLAY) {
// EGL failed at startup and playback is audio-only. Keep the
// window rather than the surface: nothing here can retry.
ANativeWindow_release(window); ANativeWindow_release(window);
window = nullptr; window = nullptr;
} }
if (window) { if (window) {
surface = eglCreateWindowSurface(display, config, window, nullptr); surface = eglCreateWindowSurface(display, config, window, nullptr);
eglMakeCurrent(display, surface, surface, context); eglMakeCurrent(display, surface, surface, context);
initialize_renderer();
} }
} }
const bool draw = frame_ready_.exchange(false); const bool draw = frame_ready_.exchange(false);
lock.unlock(); lock.unlock();
// With no window there is still a frame to consume. `vo_libmpv`
// expects its host to keep draining, and a video thread with no
// consumer stalls — which is why video came back out of sync with
// audio after the screen had been off.
if (draw && surface == EGL_NO_SURFACE && render_) {
eglMakeCurrent(display, pbuffer, pbuffer, context);
if (mpv_render_context_update(render_) & MPV_RENDER_UPDATE_FRAME) {
int skip = 1;
mpv_render_param params[] = {{MPV_RENDER_PARAM_SKIP_RENDERING, &skip}, {MPV_RENDER_PARAM_INVALID, nullptr}};
mpv_render_context_render(render_, params);
}
}
if (draw && surface != EGL_NO_SURFACE && render_) { if (draw && surface != EGL_NO_SURFACE && render_) {
eglMakeCurrent(display, surface, surface, context); eglMakeCurrent(display, surface, surface, context);
if (mpv_render_context_update(render_) & MPV_RENDER_UPDATE_FRAME) { if (mpv_render_context_update(render_) & MPV_RENDER_UPDATE_FRAME) {
@@ -394,18 +663,40 @@ class Player {
if (pending_window_) { ANativeWindow_release(pending_window_); pending_window_ = nullptr; } if (pending_window_) { ANativeWindow_release(pending_window_); pending_window_ = nullptr; }
} }
/// All of EGL, or none of it.
///
/// Leaving a display initialised behind a failed context is what made a
/// partial failure permanent: the retry guard is "is there a display", so a
/// half-built state reads as success forever and every later attach uses an
/// uninitialised config and `EGL_NO_CONTEXT`. Unwinding here keeps the guard
/// honest, and `eglGetError` says which step failed instead of leaving a
/// black screen with nothing in the log.
bool initialize_egl(EGLDisplay &display, EGLContext &context, EGLConfig &config, EGLSurface &pbuffer) { bool initialize_egl(EGLDisplay &display, EGLContext &context, EGLConfig &config, EGLSurface &pbuffer) {
const auto fail = [&](const char *step) {
__android_log_print(ANDROID_LOG_ERROR, "pstr-mpv", "%s failed: 0x%04x", step, eglGetError());
if (pbuffer != EGL_NO_SURFACE) eglDestroySurface(display, pbuffer);
if (context != EGL_NO_CONTEXT) eglDestroyContext(display, context);
if (display != EGL_NO_DISPLAY) eglTerminate(display);
display = EGL_NO_DISPLAY;
context = EGL_NO_CONTEXT;
pbuffer = EGL_NO_SURFACE;
config = nullptr;
return false;
};
display = eglGetDisplay(EGL_DEFAULT_DISPLAY); display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
if (display == EGL_NO_DISPLAY || !eglInitialize(display, nullptr, nullptr)) return false; if (display == EGL_NO_DISPLAY) return fail("eglGetDisplay");
if (!eglInitialize(display, nullptr, nullptr)) return fail("eglInitialize");
const EGLint attributes[] = {EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT, EGL_SURFACE_TYPE, EGL_WINDOW_BIT | EGL_PBUFFER_BIT, EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_NONE}; const EGLint attributes[] = {EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT, EGL_SURFACE_TYPE, EGL_WINDOW_BIT | EGL_PBUFFER_BIT, EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_NONE};
EGLint count = 0; EGLint count = 0;
if (!eglChooseConfig(display, attributes, &config, 1, &count) || count == 0) return false; if (!eglChooseConfig(display, attributes, &config, 1, &count) || count == 0) return fail("eglChooseConfig");
const EGLint context_attributes[] = {EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE}; const EGLint context_attributes[] = {EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE};
context = eglCreateContext(display, config, EGL_NO_CONTEXT, context_attributes); context = eglCreateContext(display, config, EGL_NO_CONTEXT, context_attributes);
if (context == EGL_NO_CONTEXT) return false; if (context == EGL_NO_CONTEXT) return fail("eglCreateContext");
const EGLint pbuffer_attributes[] = {EGL_WIDTH, 1, EGL_HEIGHT, 1, EGL_NONE}; const EGLint pbuffer_attributes[] = {EGL_WIDTH, 1, EGL_HEIGHT, 1, EGL_NONE};
pbuffer = eglCreatePbufferSurface(display, config, pbuffer_attributes); pbuffer = eglCreatePbufferSurface(display, config, pbuffer_attributes);
return pbuffer != EGL_NO_SURFACE && eglMakeCurrent(display, pbuffer, pbuffer, context); if (pbuffer == EGL_NO_SURFACE) return fail("eglCreatePbufferSurface");
if (!eglMakeCurrent(display, pbuffer, pbuffer, context)) return fail("eglMakeCurrent");
return true;
} }
void initialize_renderer() { void initialize_renderer() {
@@ -426,6 +717,8 @@ class Player {
std::thread renderer_; std::thread renderer_;
mutable std::mutex state_mutex_; mutable std::mutex state_mutex_;
PlaybackState state_; PlaybackState state_;
/// The first error mpv logged about the open file, until it is consumed.
std::string error_;
std::mutex render_mutex_; std::mutex render_mutex_;
std::condition_variable render_cv_; std::condition_variable render_cv_;
ANativeWindow *pending_window_ = nullptr; ANativeWindow *pending_window_ = nullptr;
@@ -465,13 +758,17 @@ bool utf8(JNIEnv *env, jstring value, std::string &result) {
void reject_null_surface(JNIEnv *env) noexcept { void reject_null_surface(JNIEnv *env) noexcept {
c_boundary([=] { c_boundary([=] {
jclass type = env->FindClass("java/lang/IllegalArgumentException"); jclass type = env->FindClass("java/lang/IllegalArgumentException");
if (type) env->ThrowNew(type, "surface must not be null"); if (!type) return;
env->ThrowNew(type, "surface must not be null");
// FindClass hands back a local reference, and this is called from a
// long-lived attached thread where the frame is not popped for us.
env->DeleteLocalRef(type);
}); });
} }
} // namespace } // namespace
extern "C" JNIEXPORT jlong JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeCreate(JNIEnv *, jobject) noexcept { extern "C" JNIEXPORT jlong JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeCreate(JNIEnv *, jclass) noexcept {
return c_boundary<jlong>(0, [] { return c_boundary<jlong>(0, [] {
auto player = std::make_unique<Player>(); auto player = std::make_unique<Player>();
return player->valid() ? reinterpret_cast<jlong>(player.release()) : 0; return player->valid() ? reinterpret_cast<jlong>(player.release()) : 0;
@@ -513,6 +810,9 @@ extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHo
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeVolume(JNIEnv *, jobject, jlong h, jdouble value) noexcept { extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeVolume(JNIEnv *, jobject, jlong h, jdouble value) noexcept {
c_boundary([=] { if (auto *p = from(h)) p->volume(value); }); c_boundary([=] { if (auto *p = from(h)) p->volume(value); });
} }
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeSpeed(JNIEnv *, jobject, jlong h, jdouble value) noexcept {
c_boundary([=] { if (auto *p = from(h)) p->speed(value); });
}
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeMute(JNIEnv *, jobject, jlong h, jboolean value) noexcept { extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeMute(JNIEnv *, jobject, jlong h, jboolean value) noexcept {
c_boundary([=] { if (auto *p = from(h)) p->mute(value == JNI_TRUE); }); c_boundary([=] { if (auto *p = from(h)) p->mute(value == JNI_TRUE); });
} }
@@ -527,19 +827,38 @@ extern "C" JNIEXPORT jdoubleArray JNICALL Java_io_narl_protonstream_playback_Nat
const auto state = from(h) ? from(h)->state() : PlaybackState{}; const auto state = from(h) ? from(h)->state() : PlaybackState{};
const jdouble values[] = {state.position, state.duration, state.volume, const jdouble values[] = {state.position, state.duration, state.volume,
state.paused ? 1.0 : 0.0, state.muted ? 1.0 : 0.0, state.paused ? 1.0 : 0.0, state.muted ? 1.0 : 0.0,
state.ended ? 1.0 : 0.0}; state.ended ? 1.0 : 0.0,
jdoubleArray result = env->NewDoubleArray(6); static_cast<jdouble>(state.generation),
state.video_width, state.video_height,
static_cast<jdouble>(state.end_reason),
state.buffering ? 1.0 : 0.0, state.cache_percent,
state.seeking ? 1.0 : 0.0};
constexpr jsize kFields = 13;
jdoubleArray result = env->NewDoubleArray(kFields);
if (!result) return static_cast<jdoubleArray>(nullptr); if (!result) return static_cast<jdoubleArray>(nullptr);
env->SetDoubleArrayRegion(result, 0, 6, values); env->SetDoubleArrayRegion(result, 0, kFields, values);
if (env->ExceptionCheck()) return static_cast<jdoubleArray>(nullptr); if (env->ExceptionCheck()) return static_cast<jdoubleArray>(nullptr);
return result; return result;
}); });
} }
extern "C" JNIEXPORT jstring JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeTakeError(JNIEnv *env, jobject, jlong h) noexcept {
return c_boundary<jstring>(nullptr, [=]() -> jstring {
auto *player = from(h);
if (!player) return nullptr;
const std::string error = player->take_error();
return error.empty() ? nullptr : string(env, error);
});
}
extern "C" JNIEXPORT jstring JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeTracks(JNIEnv *env, jobject, jlong h) noexcept { extern "C" JNIEXPORT jstring JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeTracks(JNIEnv *env, jobject, jlong h) noexcept {
return c_boundary<jstring>(nullptr, [=] { return c_boundary<jstring>(nullptr, [=] {
return string(env, from(h) ? from(h)->tracks_json() : "[]"); return string(env, from(h) ? from(h)->tracks_json() : "[]");
}); });
} }
extern "C" JNIEXPORT jstring JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeChapters(JNIEnv *env, jobject, jlong h) noexcept {
return c_boundary<jstring>(nullptr, [=] {
return string(env, from(h) ? from(h)->chapters_json() : "[]");
});
}
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_pstrAndroidStreamRelease(JNIEnv *, jobject, jlong handle) noexcept { extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_pstrAndroidStreamRelease(JNIEnv *, jobject, jlong handle) noexcept {
c_boundary([=] { pstr_android_stream_release(static_cast<uint64_t>(handle)); }); c_boundary([=] { pstr_android_stream_release(static_cast<uint64_t>(handle)); });
} }
+3
View File
@@ -8,6 +8,9 @@ extern "C" {
#endif #endif
int64_t pstr_android_stream_read(uint64_t handle, uint64_t offset, void *buffer, size_t length); int64_t pstr_android_stream_read(uint64_t handle, uint64_t offset, void *buffer, size_t length);
int64_t pstr_android_stream_size(uint64_t handle); int64_t pstr_android_stream_size(uint64_t handle);
/// Interrupts an in-flight `pstr_android_stream_read`. A cancel with no reader
/// waiting is dropped, so this is safe to call speculatively.
void pstr_android_stream_cancel(uint64_t handle);
void pstr_android_stream_release(uint64_t handle); void pstr_android_stream_release(uint64_t handle);
#ifdef __cplusplus #ifdef __cplusplus
} }
@@ -1,72 +1,151 @@
package io.narl.protonstream package io.narl.protonstream
import android.Manifest import android.Manifest
import android.content.pm.PackageManager import android.app.PendingIntent
import android.os.Bundle
import android.os.Build
import android.app.PictureInPictureParams import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.content.BroadcastReceiver
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Rect
import android.graphics.drawable.Icon
import android.os.Build
import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import android.util.Rational import android.util.Rational
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import io.narl.protonstream.ui.ProtonStreamApp import androidx.compose.runtime.mutableStateOf
import io.narl.protonstream.ui.theme.ProtonStreamTheme import androidx.compose.runtime.snapshotFlow
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import io.narl.protonstream.playback.MpvPlaybackState
import io.narl.protonstream.playback.NativeMpvHost import io.narl.protonstream.playback.NativeMpvHost
import io.narl.protonstream.playback.PlaybackService import io.narl.protonstream.playback.PlaybackService
import androidx.compose.runtime.mutableStateOf
import io.narl.protonstream.settings.SettingsStore import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.ui.ProtonStreamApp
import io.narl.protonstream.ui.theme.ProtonStreamTheme
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val playerHost = mutableStateOf<NativeMpvHost?>(null) private val playerHost = mutableStateOf<NativeMpvHost?>(null)
private val inPictureInPicture = mutableStateOf(false)
/**
* Whether a bind is outstanding, which is what has to be unbound.
*
* Set when `bindService` is called rather than when the connection arrives:
* an activity destroyed while the bind is still pending owes the system an
* `unbindService` just the same, and `onServiceConnected` may never run.
*/
private var bound = false private var bound = false
private val playbackConnection = object : ServiceConnection { private val playbackConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) { override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
playerHost.value = (service as PlaybackService.PlaybackBinder).host playerHost.value = (service as PlaybackService.PlaybackBinder).host
bound = true
} }
override fun onServiceDisconnected(name: ComponentName?) { override fun onServiceDisconnected(name: ComponentName?) {
playerHost.value = null playerHost.value = null
bound = false
} }
} }
/**
* The transport behind the buttons Android draws over the PiP window.
*
* A broadcast rather than a service intent: PiP is one of the states in
* which a background service start is refused, and the receiver is
* registered for as long as the activity that owns the window exists.
*/
private val pictureInPictureControls = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val host = playerHost.value ?: return
when (intent?.getStringExtra(EXTRA_CONTROL)) {
CONTROL_PLAY -> host.setPaused(false)
CONTROL_PAUSE -> host.setPaused(true)
CONTROL_NEXT -> host.onSkipNext?.invoke()
CONTROL_PREVIOUS -> host.onSkipPrevious?.invoke()
}
}
}
private val notificationPermission = registerForActivityResult( private val notificationPermission = registerForActivityResult(
ActivityResultContracts.RequestPermission(), ActivityResultContracts.RequestPermission(),
) { /* Downloads remain usable; Android suppresses their notifications if denied. */ } ) { /* Downloads remain usable; Android suppresses their notifications if denied. */ }
@OptIn(ExperimentalCoroutinesApi::class)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
bindService(Intent(this, PlaybackService::class.java), playbackConnection, Context.BIND_AUTO_CREATE) bound = bindService(
Intent(this, PlaybackService::class.java),
playbackConnection,
Context.BIND_AUTO_CREATE,
)
ContextCompat.registerReceiver(
this,
pictureInPictureControls,
IntentFilter(ACTION_PIP_CONTROL),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
if ( if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) { ) {
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
} }
// Auto-enter has to be armed *before* the gesture that would use it:
// building the params inside onUserLeaveHint is too late for the swipe
// home that gesture navigation sends, which never calls it. Re-register
// whenever the picture's shape or the transport's does.
lifecycleScope.launch {
snapshotFlow { playerHost.value }
.flatMapLatest { host -> host?.state ?: flowOf(null) }
.map { state -> PictureInPictureShape.of(state) }
.distinctUntilChanged()
.collect { shape -> runCatching { setPictureInPictureParams(shape.params()) } }
}
setContent { setContent {
ProtonStreamTheme { ProtonStreamTheme {
ProtonStreamApp(playerHost.value) ProtonStreamApp(playerHost.value, inPictureInPicture.value)
} }
} }
} }
override fun onUserLeaveHint() { override fun onUserLeaveHint() {
super.onUserLeaveHint() super.onUserLeaveHint()
// The backstop for launchers that still send this instead of arming
// auto-enter. Entering twice is not an error; not entering at all is.
if (playerHost.value?.hasActivePlayback == true && !isInPictureInPictureMode) { if (playerHost.value?.hasActivePlayback == true && !isInPictureInPictureMode) {
enterPictureInPictureMode( runCatching {
PictureInPictureParams.Builder().setAspectRatio(Rational(16, 9)).setAutoEnterEnabled(true).build(), enterPictureInPictureMode(PictureInPictureShape.of(playerHost.value?.state?.value).params())
) }
} }
} }
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
// The window is now a thumbnail with its own controls. Anything the
// player would draw over it — the transport, the title bar, the skip
// offer — is unreadable at that size and covers the picture.
inPictureInPicture.value = isInPictureInPictureMode
}
override fun onDestroy() { override fun onDestroy() {
if (bound) unbindService(playbackConnection) runCatching { unregisterReceiver(pictureInPictureControls) }
if (bound) {
unbindService(playbackConnection)
bound = false
}
super.onDestroy() super.onDestroy()
} }
@@ -80,4 +159,81 @@ class MainActivity : ComponentActivity() {
playerHost.value?.stop() playerHost.value?.stop()
} }
} }
/** Everything the PiP window's shape depends on, and nothing that polls. */
private data class PictureInPictureShape(
val active: Boolean,
val paused: Boolean,
val width: Int,
val height: Int,
) {
companion object {
fun of(state: MpvPlaybackState?): PictureInPictureShape = PictureInPictureShape(
active = state != null && state.duration > 0.0 && !state.ended,
paused = state?.paused != false,
width = state?.videoWidth?.toInt() ?: 0,
height = state?.videoHeight?.toInt() ?: 0,
)
}
}
private fun PictureInPictureShape.params(): PictureInPictureParams =
PictureInPictureParams.Builder()
.setAspectRatio(aspectRatio(width, height))
.setAutoEnterEnabled(active)
.setSeamlessResizeEnabled(false)
// The player owns the whole window, so the picture's rectangle is
// the window's. Without it the transition into PiP cross-fades from
// a screenshot instead of shrinking the video that is already there.
.setSourceRectHint(windowBounds())
.setActions(
listOf(
remoteAction(android.R.drawable.ic_media_previous, "Previous episode", CONTROL_PREVIOUS, 11),
if (paused) remoteAction(android.R.drawable.ic_media_play, "Play", CONTROL_PLAY, 12)
else remoteAction(android.R.drawable.ic_media_pause, "Pause", CONTROL_PAUSE, 13),
remoteAction(android.R.drawable.ic_media_next, "Next episode", CONTROL_NEXT, 14),
),
)
.build()
private fun windowBounds(): Rect = window.decorView.let { Rect(0, 0, it.width, it.height) }
private fun remoteAction(icon: Int, label: String, control: String, requestCode: Int): RemoteAction =
RemoteAction(
Icon.createWithResource(this, icon),
label,
label,
PendingIntent.getBroadcast(
this,
requestCode,
Intent(ACTION_PIP_CONTROL).setPackage(packageName).putExtra(EXTRA_CONTROL, control),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
),
)
private companion object {
const val ACTION_PIP_CONTROL = "io.narl.protonstream.PIP_CONTROL"
const val EXTRA_CONTROL = "control"
const val CONTROL_PLAY = "play"
const val CONTROL_PAUSE = "pause"
const val CONTROL_NEXT = "next"
const val CONTROL_PREVIOUS = "previous"
/**
* Android refuses a window narrower than 1:2.39 or taller than 2.39:1,
* and refuses the request outright rather than clamping it — so a
* 2.40:1 scope release would take PiP away entirely.
*/
fun aspectRatio(width: Int, height: Int): Rational {
if (width <= 0 || height <= 0) return Rational(16, 9)
val ratio = width.toDouble() / height
return when {
ratio > MAX_RATIO -> Rational(239, 100)
ratio < 1 / MAX_RATIO -> Rational(100, 239)
else -> Rational(width, height)
}
}
const val MAX_RATIO = 2.39
}
} }
@@ -1,37 +1,151 @@
package io.narl.protonstream.download package io.narl.protonstream.download
import android.content.Context import android.content.Context
import android.os.StatFs
import androidx.work.Constraints import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.workDataOf import androidx.work.workDataOf
import io.narl.protonstream.settings.SettingsStore import io.narl.protonstream.settings.SettingsStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.EpisodeRecord
object DownloadCoordinator { object DownloadCoordinator {
fun enqueue(context: Context, episode: EpisodeRecord) { /**
enqueue( * Where bulk queueing runs.
context, *
RetainedDownload(episode.shareId, episode.volumeId, episode.linkId, episode.label), * Callers are click handlers, and "Download show" on a long series is a
ExistingWorkPolicy.KEEP, * WorkManager enqueue per episode — each of which writes to its own
) * database. On the main thread that is an ANR waiting for a long enough
} * series.
*/
private val queueing = CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun enqueue(context: Context, episode: EpisodeRecord) = enqueue(context, listOf(episode))
fun resume(context: Context, download: RetainedDownload) { fun resume(context: Context, download: RetainedDownload) {
enqueue(context, download, ExistingWorkPolicy.REPLACE) enqueue(context, download, ExistingWorkPolicy.REPLACE)
} }
private fun enqueue(context: Context, download: RetainedDownload, policy: ExistingWorkPolicy) { private fun enqueue(context: Context, download: RetainedDownload, policy: ExistingWorkPolicy) {
DownloadStateStore(context).put( val queued = download.copy(status = RetainedDownload.STATUS_QUEUED, error = null)
download.copy(status = RetainedDownload.STATUS_QUEUED, error = null), DownloadStateStore(context).put(queued)
WorkManager.getInstance(context).enqueueUniqueWork(
workName(download.shareId, download.linkId),
policy,
request(queued, SettingsStore(context).wifiOnly),
) )
val request = OneTimeWorkRequestBuilder<OfflineDownloadWorker>() }
fun enqueue(context: Context, episodes: Iterable<EpisodeRecord>) {
val wanted = episodes.filterNot(EpisodeRecord::offline).toList()
if (wanted.isEmpty()) return
val application = context.applicationContext
queueing.launch {
val wifiOnly = SettingsStore(application).wifiOnly
val store = DownloadStateStore(application)
val manager = WorkManager.getInstance(application)
// Space is checked once for the whole request, against its total.
// Queueing a season that cannot fit and discovering it episode by
// episode at ENOSPC is how the app fills its own database's disk.
val room = roomFor(application, wanted.sumOf { it.size?.toLong() ?: 0L })
val downloads = wanted.map { episode ->
RetainedDownload(
episode.shareId,
episode.volumeId,
episode.linkId,
episode.label,
total = episode.size?.toLong() ?: 0L,
status = if (room) RetainedDownload.STATUS_QUEUED else RetainedDownload.STATUS_FAILED,
error = if (room) null else "Not enough free space",
)
}
store.putAll(downloads)
if (!room) return@launch
downloads.forEach { download ->
manager.enqueueUniqueWork(
workName(download.shareId, download.linkId),
ExistingWorkPolicy.KEEP,
request(download, wifiOnly),
)
}
}
}
/**
* Whether [bytes] plus working room will fit where offline files are kept.
*
* `setRequiresStorageNotLow` only holds work back at Android's own low-storage
* threshold, which a single episode can cross on its own. An unknown size —
* zero — is allowed through: refusing a download because the catalog has no
* size for it would be worse than the ENOSPC it is guarding against.
*/
private fun roomFor(context: Context, bytes: Long): Boolean {
if (bytes <= 0) return true
val available = runCatching { StatFs(context.filesDir.path).availableBytes }.getOrNull() ?: return true
return available - bytes >= STORAGE_HEADROOM
}
/**
* Re-apply the network constraint to work that is already queued.
*
* Constraints are baked in at enqueue time, so turning "Wi-Fi only" on after
* queueing a season did nothing to that season — it downloaded over cellular
* on the way out of the house, which is the one moment the setting exists
* for. Running work is left alone: WorkManager stops it on its own when the
* new constraint stops being met.
*/
fun applyNetworkPolicy(context: Context) {
val application = context.applicationContext
queueing.launch {
val wifiOnly = SettingsStore(application).wifiOnly
val manager = WorkManager.getInstance(application)
DownloadStateStore(application).records()
.filter { it.status == RetainedDownload.STATUS_QUEUED || it.status == RetainedDownload.STATUS_RUNNING }
.forEach { download ->
manager.enqueueUniqueWork(
workName(download.shareId, download.linkId),
ExistingWorkPolicy.REPLACE,
request(download, wifiOnly),
)
}
}
}
fun pause(context: Context, download: RetainedDownload) =
setStatus(context, download, RetainedDownload.STATUS_PAUSED)
fun cancel(context: Context, download: RetainedDownload) =
setStatus(context, download, RetainedDownload.STATUS_CANCELLED)
/**
* The status write goes before the work is cancelled, and through `update`
* rather than `put`: the running worker is writing progress into the same
* record, and a whole-record `put` from a screen's stale copy would both
* lose that progress and race the worker's own read-modify-write.
*/
private fun setStatus(context: Context, download: RetainedDownload, status: String) {
DownloadStateStore(context).update(download.shareId, download.linkId) { stored ->
(stored ?: download).copy(status = status, error = null)
}
WorkManager.getInstance(context).cancelUniqueWork(workName(download.shareId, download.linkId))
}
private fun request(download: RetainedDownload, wifiOnly: Boolean): OneTimeWorkRequest =
OneTimeWorkRequestBuilder<OfflineDownloadWorker>()
.setConstraints( .setConstraints(
Constraints.Builder().setRequiredNetworkType( Constraints.Builder()
requiredNetworkType(SettingsStore(context).wifiOnly), .setRequiredNetworkType(requiredNetworkType(wifiOnly))
).build(), // A download that fills the disk takes the app's own
// database down with it; the `.part` file makes waiting free.
.setRequiresStorageNotLow(true)
.build(),
) )
.setInputData( .setInputData(
workDataOf( workDataOf(
@@ -45,26 +159,6 @@ object DownloadCoordinator {
.addTag("$EPISODE_TAG${download.label}") .addTag("$EPISODE_TAG${download.label}")
.addTag(shareTag(download.shareId)) .addTag(shareTag(download.shareId))
.build() .build()
WorkManager.getInstance(context).enqueueUniqueWork(
workName(download.shareId, download.linkId),
policy,
request,
)
}
fun enqueue(context: Context, episodes: Iterable<EpisodeRecord>) {
episodes.filterNot(EpisodeRecord::offline).forEach { enqueue(context, it) }
}
fun pause(context: Context, download: RetainedDownload) {
DownloadStateStore(context).put(download.copy(status = RetainedDownload.STATUS_PAUSED, error = null))
WorkManager.getInstance(context).cancelUniqueWork(workName(download.shareId, download.linkId))
}
fun cancel(context: Context, download: RetainedDownload) {
DownloadStateStore(context).put(download.copy(status = RetainedDownload.STATUS_CANCELLED))
WorkManager.getInstance(context).cancelUniqueWork(workName(download.shareId, download.linkId))
}
fun shareTag(shareId: String) = "$SHARE_TAG$shareId" fun shareTag(shareId: String) = "$SHARE_TAG$shareId"
fun workName(shareId: String, linkId: String) = "offline-$shareId-$linkId" fun workName(shareId: String, linkId: String) = "offline-$shareId-$linkId"
@@ -72,6 +166,9 @@ object DownloadCoordinator {
const val TAG = "offline-download" const val TAG = "offline-download"
const val EPISODE_TAG = "offline-episode:" const val EPISODE_TAG = "offline-episode:"
private const val SHARE_TAG = "offline-share:" private const val SHARE_TAG = "offline-share:"
/** Free space to leave behind a download, for the catalog and the caches. */
private const val STORAGE_HEADROOM = 256L * 1024 * 1024
} }
internal fun requiredNetworkType(wifiOnly: Boolean): NetworkType = internal fun requiredNetworkType(wifiOnly: Boolean): NetworkType =
@@ -35,18 +35,59 @@ class DownloadStateStore(context: Context) {
fun get(shareId: String, linkId: String): RetainedDownload? = fun get(shareId: String, linkId: String): RetainedDownload? =
preferences.getString(key(shareId, linkId), null)?.let { runCatching { decode(it) }.getOrNull() } preferences.getString(key(shareId, linkId), null)?.let { runCatching { decode(it) }.getOrNull() }
fun put(record: RetainedDownload) { fun put(record: RetainedDownload) = synchronized(WRITES) {
preferences.edit().putString(record.key, encode(record)).commit() edit { putString(record.key, encode(record)) }
} }
fun remove(shareId: String, linkId: String) { /**
preferences.edit().remove(key(shareId, linkId)).commit() * Read-modify-write one record, excluding every other writer.
*
* The worker's progress update reads the stored record to decide whether the
* viewer has paused, then writes it back. `pause()` is another writer, and
* without this a `put` landing between those two halves puts `RUNNING` back
* over `PAUSED` — after which the worker's own cancellation handler sees a
* status that is not `PAUSED` and records `CANCELLED`. The pause is simply
* lost, which is the visible bug.
*
* Returning null from [change] leaves the record alone.
*/
fun update(shareId: String, linkId: String, change: (RetainedDownload?) -> RetainedDownload?) =
synchronized(WRITES) {
change(get(shareId, linkId))?.let { edit { putString(it.key, encode(it)) } }
}
/**
* Write a whole queue in one go.
*
* Queueing a season is one call, not one per episode: `commit()` per record
* on a 200-episode series was 200 synchronous fsyncs, and the callers are
* click handlers.
*/
fun putAll(records: Collection<RetainedDownload>) = synchronized(WRITES) {
edit { records.forEach { putString(it.key, encode(it)) } }
} }
fun removeShare(shareId: String) { fun remove(shareId: String, linkId: String) = synchronized(WRITES) {
val edit = preferences.edit() edit { remove(key(shareId, linkId)) }
records().filter { it.shareId == shareId }.forEach { edit.remove(it.key) } }
edit.commit()
/** Forget every retained download. Callers cancel the work first. */
fun clear() = synchronized(WRITES) { edit { clear() } }
fun removeShare(shareId: String) = synchronized(WRITES) {
edit { records().filter { it.shareId == shareId }.forEach { remove(it.key) } }
}
/**
* `apply()`, not `commit()`.
*
* The in-memory map is updated before this returns, so a read that follows a
* write in the same process still sees it; only the disk write is deferred,
* and losing the newest progress reading to a kill is not worth an fsync on
* whichever thread happened to be writing.
*/
private inline fun edit(block: android.content.SharedPreferences.Editor.() -> Unit) {
preferences.edit().apply(block).apply()
} }
private fun encode(record: RetainedDownload) = JSONObject() private fun encode(record: RetainedDownload) = JSONObject()
@@ -78,5 +119,11 @@ class DownloadStateStore(context: Context) {
private companion object { private companion object {
const val NAME = "offline_download_state" const val NAME = "offline_download_state"
/**
* One lock for the whole process. Instances of this class are created
* ad hoc from a `Context`, so the lock cannot live on one of them.
*/
val WRITES = Any()
} }
} }
@@ -4,6 +4,7 @@ import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.content.Context import android.content.Context
import android.os.SystemClock
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.Data import androidx.work.Data
@@ -23,6 +24,7 @@ class OfflineDownloadWorker(
parameters: WorkerParameters, parameters: WorkerParameters,
) : CoroutineWorker(appContext, parameters) { ) : CoroutineWorker(appContext, parameters) {
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
createNotificationChannel()
setForeground(createForegroundInfo(0)) setForeground(createForegroundInfo(0))
val shareId = inputData.getString(KEY_SHARE_ID) ?: return Result.failure(error("missing share")) val shareId = inputData.getString(KEY_SHARE_ID) ?: return Result.failure(error("missing share"))
val volumeId = inputData.getString(KEY_VOLUME_ID) ?: return Result.failure(error("missing volume")) val volumeId = inputData.getString(KEY_VOLUME_ID) ?: return Result.failure(error("missing volume"))
@@ -35,20 +37,43 @@ class OfflineDownloadWorker(
store.put(retained) store.put(retained)
val observer = object : DownloadObserver { val observer = object : DownloadObserver {
private var reportedAt = 0L
private var reportedPercent = -1
/**
* Called once per 4 MiB block — a dozen times a second on a fast
* link, each one a WorkManager database write, a notification post
* and a preferences write. Nothing the viewer can see changes that
* often, so a report is only made when the percentage moves or a
* second has passed. The final one is never suppressed.
*/
override fun onProgress(downloaded: ULong, total: ULong) { override fun onProgress(downloaded: ULong, total: ULong) {
val percent = if (total == 0UL) 0 else ((downloaded * 100UL) / total).toInt() val percent = if (total == 0UL) 0 else ((downloaded * 100UL) / total).toInt()
val now = SystemClock.elapsedRealtime()
val complete = total > 0UL && downloaded >= total
if (!complete && percent == reportedPercent && now - reportedAt < PROGRESS_INTERVAL_MS) {
return
}
reportedAt = now
reportedPercent = percent
setProgressAsync(workDataOf(KEY_DOWNLOADED to downloaded.toLong(), KEY_TOTAL to total.toLong())) setProgressAsync(workDataOf(KEY_DOWNLOADED to downloaded.toLong(), KEY_TOTAL to total.toLong()))
setForegroundAsync(createForegroundInfo(percent)) setForegroundAsync(createForegroundInfo(percent))
val requested = store.get(shareId, linkId) // Under the store's lock: `pause()` is a concurrent writer, and
val requestedStatus = requested?.status // this read-modify-write used to put RUNNING back over the
retained = (requested ?: retained).copy( // viewer's PAUSED and then report the download as cancelled.
downloaded = downloaded.toLong(), store.update(shareId, linkId) { stored ->
total = total.toLong(), val current = stored ?: retained
status = if (requestedStatus == RetainedDownload.STATUS_PAUSED || current.copy(
requestedStatus == RetainedDownload.STATUS_CANCELLED downloaded = downloaded.toLong(),
) requestedStatus else RetainedDownload.STATUS_RUNNING, total = total.toLong(),
) status = when (current.status) {
store.put(retained) RetainedDownload.STATUS_PAUSED,
RetainedDownload.STATUS_CANCELLED,
-> current.status
else -> RetainedDownload.STATUS_RUNNING
},
).also { retained = it }
}
} }
override fun isCancelled(): Boolean = isStopped override fun isCancelled(): Boolean = isStopped
@@ -62,9 +87,12 @@ class OfflineDownloadWorker(
}, },
onFailure = { failure -> onFailure = { failure ->
if (isStopped) { if (isStopped) {
val current = store.get(shareId, linkId) ?: retained // A pause is also a stop. Under the lock, so the record read
if (current.status != RetainedDownload.STATUS_PAUSED) { // here is the one the write lands on.
store.put(current.copy(status = RetainedDownload.STATUS_CANCELLED)) store.update(shareId, linkId) { stored ->
val current = stored ?: retained
current.takeIf { it.status != RetainedDownload.STATUS_PAUSED }
?.copy(status = RetainedDownload.STATUS_CANCELLED)
} }
Result.failure(error("cancelled")) Result.failure(error("cancelled"))
} else if (runAttemptCount < MAX_RETRIES) { } else if (runAttemptCount < MAX_RETRIES) {
@@ -79,11 +107,14 @@ class OfflineDownloadWorker(
) )
} }
private fun createForegroundInfo(progress: Int): ForegroundInfo { /** Once per run, rather than once per 4 MiB block. */
val manager = applicationContext.getSystemService(NotificationManager::class.java) private fun createNotificationChannel() {
manager.createNotificationChannel( applicationContext.getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Offline downloads", NotificationManager.IMPORTANCE_LOW), NotificationChannel(CHANNEL_ID, "Offline downloads", NotificationManager.IMPORTANCE_LOW),
) )
}
private fun createForegroundInfo(progress: Int): ForegroundInfo {
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground) .setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
.setContentTitle("Making episode available offline") .setContentTitle("Making episode available offline")
@@ -109,6 +140,9 @@ class OfflineDownloadWorker(
const val KEY_TOTAL = "total" const val KEY_TOTAL = "total"
private const val MAX_RETRIES = 3 private const val MAX_RETRIES = 3
private const val CHANNEL_ID = "offline-downloads" private const val CHANNEL_ID = "offline-downloads"
/** Shortest gap between two identical-looking progress reports. */
private const val PROGRESS_INTERVAL_MS = 1_000L
} }
} }
@@ -2,6 +2,7 @@ package io.narl.protonstream.native
import android.content.Context import android.content.Context
import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.security.keystore.KeyProperties import android.security.keystore.KeyProperties
import android.util.Base64 import android.util.Base64
import java.security.KeyStore import java.security.KeyStore
@@ -10,22 +11,44 @@ import javax.crypto.KeyGenerator
import javax.crypto.SecretKey import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.GCMParameterSpec
import uniffi.pstr_android.AndroidSecretStore import uniffi.pstr_android.AndroidSecretStore
import uniffi.pstr_android.BridgeException
/** Encrypts share fragments and passwords before they reach app preferences. */ /**
* Encrypts share fragments and passwords before they reach app preferences.
*
* Every failure here leaves as a [BridgeException]. Raw Java exceptions crossing
* the FFI have no mapping on the Rust side, and an unmapped callback exception
* is a panic on whichever thread was calling — including a tokio worker holding
* a download. What the caller does about it is a separate question; being told
* at all is this class's part.
*/
class KeystoreSecretStore(context: Context) : AndroidSecretStore { class KeystoreSecretStore(context: Context) : AndroidSecretStore {
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
override fun set(key: String, value: String) { override fun set(key: String, value: String) = bridging("store credential") {
val cipher = Cipher.getInstance(TRANSFORMATION) val payload = try {
cipher.init(Cipher.ENCRYPT_MODE, encryptionKey()) seal(value)
val encrypted = cipher.doFinal(value.toByteArray(Charsets.UTF_8)) } catch (invalidated: KeyPermanentlyInvalidatedException) {
val payload = cipher.iv + encrypted // A lockscreen change or a device restore retires the key. Nothing
// it sealed can be read again — and nothing new can be sealed with
// it either, so re-entering a link would fail here rather than
// restoring access. Everything the old key held is already lost, so
// replacing it costs nothing that is not gone.
keyStore.deleteEntry(KEY_ALIAS)
seal(value)
}
check(preferences.edit().putString(key, Base64.encodeToString(payload, Base64.NO_WRAP)).commit()) check(preferences.edit().putString(key, Base64.encodeToString(payload, Base64.NO_WRAP)).commit())
} }
override fun get(key: String): String? { private fun seal(value: String): ByteArray {
val encoded = preferences.getString(key, null) ?: return null val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, encryptionKey())
return cipher.iv + cipher.doFinal(value.toByteArray(Charsets.UTF_8))
}
override fun get(key: String): String? = bridging("read credential") {
val encoded = preferences.getString(key, null) ?: return@bridging null
val payload = Base64.decode(encoded, Base64.NO_WRAP) val payload = Base64.decode(encoded, Base64.NO_WRAP)
require(payload.size > IV_SIZE) { "Invalid encrypted credential" } require(payload.size > IV_SIZE) { "Invalid encrypted credential" }
val cipher = Cipher.getInstance(TRANSFORMATION) val cipher = Cipher.getInstance(TRANSFORMATION)
@@ -34,13 +57,23 @@ class KeystoreSecretStore(context: Context) : AndroidSecretStore {
encryptionKey(), encryptionKey(),
GCMParameterSpec(TAG_BITS, payload.copyOfRange(0, IV_SIZE)), GCMParameterSpec(TAG_BITS, payload.copyOfRange(0, IV_SIZE)),
) )
return cipher.doFinal(payload.copyOfRange(IV_SIZE, payload.size)).toString(Charsets.UTF_8) cipher.doFinal(payload.copyOfRange(IV_SIZE, payload.size)).toString(Charsets.UTF_8)
} }
override fun delete(key: String) { override fun delete(key: String) = bridging("delete credential") {
check(preferences.edit().remove(key).commit()) check(preferences.edit().remove(key).commit())
} }
private fun <T> bridging(action: String, block: () -> T): T = try {
block()
} catch (error: BridgeException) {
throw error
} catch (error: Exception) {
// The message, never the stack trace: this reaches a snackbar, and the
// trace is both unreadable there and a description of app internals.
throw BridgeException.Failure("Could not $action: ${error.message ?: error::class.java.simpleName}")
}
private fun encryptionKey(): SecretKey { private fun encryptionKey(): SecretKey {
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it } (keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run { return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run {
@@ -12,14 +12,23 @@ object NativeRuntime {
private lateinit var appContext: Context private lateinit var appContext: Context
@Volatile private var instance: AndroidEngine? = null @Volatile private var instance: AndroidEngine? = null
/**
* Whether Android's certificate verifier came up.
*
* False means every HTTPS request will fail validation, which otherwise
* surfaces much later as an unexplained certificate error against a share.
*/
@Volatile var tlsReady: Boolean = false
private set
fun initialize(context: Context) { fun initialize(context: Context) {
System.loadLibrary("pstr_android") System.loadLibrary("pstr_android")
initTls(context) tlsReady = initTls(context)
appContext = context.applicationContext appContext = context.applicationContext
} }
/** Initializes Android's certificate verifier before Proton networking. */ /** Initializes Android's certificate verifier before Proton networking. */
private external fun initTls(context: Context) private external fun initTls(context: Context): Boolean
suspend fun engine(): AndroidEngine = withContext(Dispatchers.IO) { suspend fun engine(): AndroidEngine = withContext(Dispatchers.IO) {
instance ?: synchronized(this@NativeRuntime) { instance ?: synchronized(this@NativeRuntime) {
@@ -1,343 +0,0 @@
package io.narl.protonstream.playback
import android.view.Surface
import android.view.SurfaceHolder
import android.view.SurfaceView
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.clickable
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.Fullscreen
import androidx.compose.material.icons.filled.FullscreenExit
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material.icons.filled.VolumeOff
import androidx.compose.material.icons.filled.Subtitles
import androidx.compose.material.icons.filled.Audiotrack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import io.narl.protonstream.native.NativeRuntime
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import uniffi.pstr_android.AndroidEngine
import uniffi.pstr_android.AndroidStream
import uniffi.pstr_android.EpisodeRecord
import kotlin.math.roundToInt
/** Exact seekable stream contract a bundled libmpv JNI adapter must consume. */
class RustStreamSession(
private val episode: EpisodeRecord,
) {
private var engine: AndroidEngine? = null
private var stream: AndroidStream? = null
private var handle: ULong? = null
private var streamSize: ULong? = null
suspend fun open(): RustStreamSession = withContext(Dispatchers.IO) {
val openedEngine = NativeRuntime.engine()
engine = openedEngine
stream = openedEngine.openStream(episode.shareId, episode.volumeId, episode.linkId).also {
streamSize = it.size()
handle = it.nativeHandle()
}
this@RustStreamSession
}
fun nativeHandle(): ULong = checkNotNull(handle) { "stream is not published" }
fun size(): ULong = checkNotNull(streamSize) { "stream is not open" }
fun releaseNative(host: LibmpvHost) {
handle?.let(host::releaseNativeStream)
handle = null
streamSize = null
stream = null
}
/** The native player now owns the published token until stop/end/close. */
fun transferToPlayer() {
handle = null
streamSize = null
stream = null
}
suspend fun closeEngine() = withContext(Dispatchers.IO) {
engine?.releaseStream(episode.shareId, episode.volumeId, episode.linkId)
engine = null
}
}
/** Implement only in the variant that bundles a pinned libmpv native artifact. */
interface LibmpvHost {
fun attachSurface(surface: Surface)
fun detachSurface()
/** Consumes the opaque handle through pstr_android_stream_{read,size}. */
suspend fun play(
nativeHandle: ULong,
size: ULong,
startPosition: Double = 0.0,
audioLanguage: String? = null,
subtitleLanguage: String? = null,
subtitles: Boolean = true,
)
/** Calls pstr_android_stream_release exactly once for a published handle. */
fun releaseNativeStream(nativeHandle: ULong)
}
private data class PlaybackStartup(
val nativeHandle: ULong,
val size: ULong,
val startPosition: Double,
val audioLanguage: String?,
val subtitleLanguage: String?,
val subtitles: Boolean,
)
@Composable
fun LibmpvPlayerSurface(
titleKey: String,
episode: EpisodeRecord,
host: LibmpvHost? = null,
isFullscreen: Boolean = false,
onToggleFullscreen: () -> Unit = {}
) {
if (host == null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Playback is unavailable: this build does not include libmpv.", color = Color.White)
}
return
}
val scope = rememberCoroutineScope()
var playbackError by remember(sessionKey(episode)) { mutableStateOf<String?>(null) }
val session = remember(episode.shareId, episode.linkId) {
RustStreamSession(episode)
}
LaunchedEffect(session, host) {
runCatching {
val startup = withContext(Dispatchers.IO) {
session.open()
val engine = NativeRuntime.engine()
val preferences = engine.titleTrackPreferences(titleKey)
val watch = engine.watchState(episode.shareId, episode.linkId)
PlaybackStartup(
nativeHandle = session.nativeHandle(),
size = session.size(),
startPosition = watch?.positionSecs ?: 0.0,
audioLanguage = preferences.audioLanguage,
subtitleLanguage = preferences.subtitleLanguage,
subtitles = preferences.subtitles,
)
}
host.play(
startup.nativeHandle, startup.size, startup.startPosition,
startup.audioLanguage, startup.subtitleLanguage, startup.subtitles,
)
session.transferToPlayer()
}.onFailure { playbackError = it.message ?: "Playback failed" }
}
DisposableEffect(session, host) {
onDispose {
host.detachSurface()
session.releaseNative(host)
scope.launch { session.closeEngine() }
}
}
var showControls by remember { mutableStateOf(true) }
LaunchedEffect(showControls) {
if (showControls) {
delay(4000)
showControls = false
}
}
Box(Modifier.fillMaxSize().clickable(
interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() },
indication = null,
onClick = { showControls = !showControls }
)) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
SurfaceView(context).apply {
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) = host.attachSurface(holder.surface)
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) = host.attachSurface(holder.surface)
override fun surfaceDestroyed(holder: SurfaceHolder) = host.detachSurface()
})
}
},
)
AnimatedVisibility(
visible = showControls,
modifier = Modifier.align(Alignment.BottomCenter),
enter = fadeIn(),
exit = fadeOut()
) {
if (host is NativeMpvHost) PlayerControls(
titleKey,
episode,
host,
isFullscreen,
onToggleFullscreen
)
}
playbackError?.let { message ->
Text(message, color = Color.White, modifier = Modifier.align(Alignment.Center).background(Color.Black.copy(alpha = 0.8f)).padding(16.dp))
}
}
}
private fun sessionKey(episode: EpisodeRecord) = "${episode.shareId}:${episode.linkId}"
@Composable
private fun PlayerControls(
titleKey: String,
episode: EpisodeRecord,
host: NativeMpvHost,
isFullscreen: Boolean,
onToggleFullscreen: () -> Unit,
modifier: Modifier = Modifier
) {
val state by host.state.collectAsState()
val tracks by host.tracks.collectAsState()
val scope = rememberCoroutineScope()
var choosing by remember { mutableStateOf<String?>(null) }
LaunchedEffect(state.position.roundToInt() / 5) {
if (state.duration > 0.0) withContext(Dispatchers.IO) {
NativeRuntime.engine().saveWatchState(
episode.shareId, episode.linkId, state.position.coerceAtMost(state.duration), state.duration,
state.ended || state.position >= state.duration * 0.9,
)
}
}
Column(modifier.fillMaxWidth().background(Color.Black.copy(alpha = 0.68f)).padding(12.dp)) {
Slider(
value = state.position.toFloat().coerceIn(0f, state.duration.toFloat().coerceAtLeast(1f)),
onValueChange = { host.seek(it.toDouble()) },
valueRange = 0f..state.duration.toFloat().coerceAtLeast(1f),
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = { host.setPaused(!state.paused) }) {
Icon(if (state.paused) Icons.Default.PlayArrow else Icons.Default.Pause, contentDescription = "Play/Pause", tint = Color.White)
}
Text("${formatTime(state.position)} / ${formatTime(state.duration)}", color = Color.White)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
IconButton(onClick = { host.setMuted(!state.muted) }) {
Icon(if (state.muted) Icons.Default.VolumeOff else Icons.Default.VolumeUp, contentDescription = "Mute", tint = Color.White)
}
IconButton(onClick = onToggleFullscreen) {
Icon(if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen, contentDescription = "Fullscreen", tint = Color.White)
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = { choosing = "audio" }) {
Icon(Icons.Default.Audiotrack, contentDescription = "Audio", tint = Color.White)
}
IconButton(onClick = { choosing = "sub" }) {
Icon(Icons.Default.Subtitles, contentDescription = "Subtitles", tint = Color.White)
}
Text(
"Audio: ${tracks.firstOrNull { it.type == "audio" && it.selected }?.label ?: "default"} • " +
"Subtitles: ${tracks.firstOrNull { it.type == "sub" && it.selected }?.label ?: "off"}",
color = Color.White,
style = MaterialTheme.typography.bodySmall
)
}
}
choosing?.let { type ->
TrackChooser(
type = type,
tracks = tracks.filter { it.type == type },
onDismiss = { choosing = null },
onSelect = { selected ->
host.selectTrack(selected)
choosing = null
scope.launch(Dispatchers.IO) {
val engine = NativeRuntime.engine()
val old = engine.titleTrackPreferences(titleKey)
engine.setTitleTrackPreferences(
titleKey,
if (type == "audio") old.copy(audioLanguage = selected?.language?.takeIf(String::isNotBlank))
else old.copy(
subtitleLanguage = selected?.language?.takeIf(String::isNotBlank),
subtitles = selected != null,
),
)
}
},
)
}
}
@Composable
private fun TrackChooser(
type: String,
tracks: List<MpvTrack>,
onDismiss: () -> Unit,
onSelect: (MpvTrack?) -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(if (type == "audio") "Audio track" else "Subtitle track") },
text = {
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
if (type == "sub") {
item {
OutlinedButton(onClick = { onSelect(null) }, Modifier.fillMaxWidth()) {
Text(if (tracks.none(MpvTrack::selected)) "✓ Off" else "Off")
}
}
}
items(tracks, key = MpvTrack::id) { track ->
OutlinedButton(onClick = { onSelect(track) }, Modifier.fillMaxWidth()) {
Text(if (track.selected) "✓ ${track.label}" else track.label)
}
}
}
},
confirmButton = { Button(onClick = onDismiss) { Text("Close") } },
)
}
private fun formatTime(seconds: Double): String {
val total = seconds.coerceAtLeast(0.0).roundToInt()
return "%d:%02d".format(total / 60, total % 60)
}
@@ -1,6 +1,7 @@
package io.narl.protonstream.playback package io.narl.protonstream.playback
import android.view.Surface import android.view.Surface
import io.narl.protonstream.ui.ThumbnailSource
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -16,6 +17,31 @@ import kotlinx.coroutines.withContext
import org.json.JSONArray import org.json.JSONArray
import java.util.concurrent.locks.ReentrantLock import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock import kotlin.concurrent.withLock
import uniffi.pstr_android.ChapterPlan
import uniffi.pstr_android.ChapterRecord
import uniffi.pstr_android.chapterPlan
/**
* Why the open file ended, mirroring `pstr_player::EndReason` so both clients
* agree on what "finished" means.
*
* Only [Eof] is an episode watched to the end. Treating the others as one — a
* stop, a shutdown, a failed demux — is what marks an episode that never played
* as watched and autoplays past it.
*/
enum class EndReason {
None,
Eof,
Stopped,
Quit,
Failed,
Other,
;
companion object {
fun of(value: Double): EndReason = entries.getOrElse(value.toInt()) { Other }
}
}
data class MpvPlaybackState( data class MpvPlaybackState(
val position: Double = 0.0, val position: Double = 0.0,
@@ -24,6 +50,53 @@ data class MpvPlaybackState(
val paused: Boolean = true, val paused: Boolean = true,
val muted: Boolean = false, val muted: Boolean = false,
val ended: Boolean = false, val ended: Boolean = false,
/** Meaningful only while [ended]; [EndReason.None] otherwise. */
val endReason: EndReason = EndReason.None,
/**
* Which episode this reading belongs to, or null before the first poll of a
* newly loaded file. Everything that *writes* per-episode state — resume
* position above all — must check this: one host serves every episode, and
* a reading attributed to the wrong one resumes the next episode where the
* last one stopped.
*/
val media: String? = null,
/** The picture's display size, or zero until mpv has decoded enough to know. */
val videoWidth: Double = 0.0,
val videoHeight: Double = 0.0,
/**
* mpv has run out of buffered data and stopped to refill.
*
* Not the same as [paused], which is the viewer's doing. A frozen picture
* that nothing explains is indistinguishable from a hung app, which is what
* a network-backed stream looked like before this existed.
*/
val buffering: Boolean = false,
/** How full the demuxer cache is while [buffering], 0–100. */
val cachePercent: Double = 0.0,
/** A seek has been issued but playback has not resumed at the new position. */
val seeking: Boolean = false,
) {
/** Whether the picture is stopped for a reason the viewer did not choose. */
val stalled: Boolean get() = (buffering || seeking) && !paused && !ended
}
/**
* What is on screen, for the surfaces that are not the player.
*
* The media session, its notification and Picture-in-Picture all have to name
* the episode, and none of them can reach the composition that knows it. The
* player publishes this once per episode and they read it.
*/
data class NowPlaying(
val media: String,
val title: String,
val show: String?,
val detail: String?,
val artworkUrl: String?,
/** The file to pull a Proton thumbnail from when the provider has no poster. */
val artworkFile: ThumbnailSource?,
val hasPrevious: Boolean,
val hasNext: Boolean,
) )
data class MpvTrack( data class MpvTrack(
@@ -38,21 +111,61 @@ data class MpvTrack(
} }
/** Process-local owner of the libmpv core. PlaybackService owns its lifetime. */ /** Process-local owner of the libmpv core. PlaybackService owns its lifetime. */
class NativeMpvHost : LibmpvHost, AutoCloseable { class NativeMpvHost private constructor(private val nativeHandle: Long) : LibmpvHost, AutoCloseable {
private val nativeHandle = nativeCreate().also { check(it != 0L) { "libmpv initialization failed" } }
private val lifecycle = ReentrantLock() private val lifecycle = ReentrantLock()
@Volatile private var closed = false @Volatile private var closed = false
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val mutableState = MutableStateFlow(MpvPlaybackState()) private val mutableState = MutableStateFlow(MpvPlaybackState())
private val mutableTracks = MutableStateFlow(emptyList<MpvTrack>()) private val mutableTracks = MutableStateFlow(emptyList<MpvTrack>())
private val mutableChapters = MutableStateFlow(ChapterPlan(emptyList(), null))
private val mutableNowPlaying = MutableStateFlow<NowPlaying?>(null)
private val mutableAdvancing = MutableStateFlow(false)
private val mutableProblem = MutableStateFlow<String?>(null)
private var poller: Job? = null private var poller: Job? = null
private var currentStreamHandle: Long? = null /** The episode key and load generation the native player currently holds. */
@Volatile private var currentMedia: String? = null
@Volatile private var currentGeneration: Double = -1.0
@Volatile private var plannedFor: Pair<Double, Long>? = null
var onPlaybackStarted: (() -> Unit)? = null var onPlaybackStarted: (() -> Unit)? = null
var onStateChanged: ((MpvPlaybackState) -> Unit)? = null var onStateChanged: ((MpvPlaybackState) -> Unit)? = null
var onExplicitStop: (() -> Unit)? = null var onExplicitStop: (() -> Unit)? = null
/**
* Where a transport outside the player sends previous/next.
*
* The player owns which episode is open, so the notification, the lock
* screen and Picture-in-Picture cannot walk the playlist themselves. Both
* run on the main thread, which is where the player's state lives.
*/
var onSkipPrevious: (() -> Unit)? = null
var onSkipNext: (() -> Unit)? = null
val state: StateFlow<MpvPlaybackState> = mutableState.asStateFlow() val state: StateFlow<MpvPlaybackState> = mutableState.asStateFlow()
val tracks: StateFlow<List<MpvTrack>> = mutableTracks.asStateFlow() val tracks: StateFlow<List<MpvTrack>> = mutableTracks.asStateFlow()
/** The open file's chapters, already resolved into openings and endings. */
val chapters: StateFlow<ChapterPlan> = mutableChapters.asStateFlow()
/** The episode the player has open, for every surface that has to name it. */
val nowPlaying: StateFlow<NowPlaying?> = mutableNowPlaying.asStateFlow()
/**
* True while the player is between episodes.
*
* The end of one episode and the start of the next are separated by a
* network round trip — opening the next revision's stream. The foreground
* service must not retire in that gap: with no foreground state left, the
* `startForeground` on the other side is a background start, which API 31+
* refuses outright.
*/
val advancing: StateFlow<Boolean> = mutableAdvancing.asStateFlow()
/**
* What mpv last complained about, until the viewer dismisses it.
*
* Errors mpv reports are about the *file* — an unreadable header, a codec it
* has no decoder for — and reach nothing else: the play call has already
* returned successfully by the time they happen.
*/
val problem: StateFlow<String?> = mutableProblem.asStateFlow()
/** Dismiss the current [problem]. */
fun clearProblem() { mutableProblem.value = null }
val hasActivePlayback: Boolean get() = mutableState.value.duration > 0.0 && !mutableState.value.ended val hasActivePlayback: Boolean get() = mutableState.value.duration > 0.0 && !mutableState.value.ended
override fun attachSurface(surface: Surface) { override fun attachSurface(surface: Surface) {
@@ -64,6 +177,7 @@ class NativeMpvHost : LibmpvHost, AutoCloseable {
} }
override suspend fun play( override suspend fun play(
media: String,
nativeHandle: ULong, nativeHandle: ULong,
size: ULong, size: ULong,
startPosition: Double, startPosition: Double,
@@ -72,69 +186,172 @@ class NativeMpvHost : LibmpvHost, AutoCloseable {
subtitles: Boolean, subtitles: Boolean,
) { ) {
check(size > 0uL) { "Cannot play an empty stream" } check(size > 0uL) { "Cannot play an empty stream" }
withContext(Dispatchers.IO) { try {
val loaded = withOpenHandle { withContext(Dispatchers.IO) {
nativeLoad(it, nativeHandle.toLong(), startPosition, audioLanguage, subtitleLanguage, subtitles).also { loaded -> val loaded = withOpenHandle {
if (loaded) { nativeLoad(it, nativeHandle.toLong(), startPosition, audioLanguage, subtitleLanguage, subtitles).also { loaded ->
currentStreamHandle?.let(::pstrAndroidStreamRelease) if (loaded) {
currentStreamHandle = nativeHandle.toLong() // The outgoing stream is released by mpv closing it,
// not from here: `loadfile` is asynchronous and its
// demuxer may still be reading. `stream_close` in
// `pstr_mpv.cpp` owns that release.
//
// The outgoing file's readings describe an episode
// that is no longer open. Retire them here as well
// as natively, so nothing observes them even once.
currentMedia = media
currentGeneration = withOpenHandle(::nativeState)?.generation() ?: -1.0
mutableState.value = MpvPlaybackState(
position = startPosition,
volume = mutableState.value.volume,
muted = mutableState.value.muted,
media = media,
)
mutableProblem.value = null
mutableTracks.value = emptyList()
mutableChapters.value = ChapterPlan(emptyList(), null)
plannedFor = null
}
} }
} ?: false
check(loaded) {
"libmpv rejected the stream or no video surface was available"
} }
} ?: false
check(loaded) {
"libmpv rejected the stream or no video surface was available"
} }
} withOpenHandle {
withOpenHandle { onPlaybackStarted?.invoke()
onPlaybackStarted?.invoke() startPolling()
startPolling() }
} finally {
// Whether the next episode arrived or failed to, the gap the
// foreground service was holding open is over.
mutableAdvancing.value = false
} }
} }
fun setPaused(paused: Boolean) { withOpenHandle { nativePause(it, paused) } } /**
* Declare that the player is walking to another episode.
*
* Called before the stream for the next one is opened, and cleared when
* [play] returns either way — the window this covers is the one in which
* nothing is loaded and the service would otherwise conclude playback is
* finished.
*/
fun beginTransition() { mutableAdvancing.value = true }
/** Names the open episode for the media session, its notification and PiP. */
fun publish(playing: NowPlaying?) { mutableNowPlaying.value = playing }
fun setPaused(paused: Boolean) {
// The native side writes the flag through, so every transport can flip
// now instead of on the next poll a quarter second from here.
val next = withOpenHandle {
nativePause(it, paused)
mutableState.value.copy(paused = paused).also { state -> mutableState.value = state }
} ?: return
onStateChanged?.invoke(next)
}
fun seek(position: Double) { withOpenHandle { nativeSeek(it, position) } } fun seek(position: Double) { withOpenHandle { nativeSeek(it, position) } }
fun setVolume(volume: Double) { withOpenHandle { nativeVolume(it, volume) } } fun setVolume(volume: Double) { withOpenHandle { nativeVolume(it, volume) } }
fun setMuted(muted: Boolean) { withOpenHandle { nativeMute(it, muted) } } fun setMuted(muted: Boolean) { withOpenHandle { nativeMute(it, muted) } }
/** Playback rate, 1.0 being the file's own. Clamped native-side. */
fun setSpeed(speed: Double) { withOpenHandle { nativeSpeed(it, speed) } }
fun selectTrack(track: MpvTrack?) { fun selectTrack(track: MpvTrack?) {
val audio = track?.type == "audio" val audio = track?.type == "audio"
withOpenHandle { nativeSelectTrack(it, audio, track?.id ?: -1) } withOpenHandle { nativeSelectTrack(it, audio, track?.id ?: -1) }
refreshTracks() refreshTracks()
} }
/** Explicit user stop: stop media, release its stream, and retire the foreground service. */ /**
* Explicit user stop: stop media and retire the foreground service.
*
* The stream behind the open file is released by mpv closing it, which
* `stop` causes; releasing it from here would race that close.
*/
fun stop() { fun stop() {
val callback = withOpenHandle { val callback = withOpenHandle {
nativeStop(it) nativeStop(it)
currentStreamHandle?.let(::pstrAndroidStreamRelease) // Nothing loaded means nothing to attribute a reading to: a save
currentStreamHandle = null // issued after this must not land on the episode just stopped.
currentMedia = null
mutableAdvancing.value = false
mutableChapters.value = ChapterPlan(emptyList(), null)
mutableNowPlaying.value = null
onExplicitStop onExplicitStop
} }
callback?.invoke() callback?.invoke()
} }
internal fun releaseEndedStream() {
withOpenHandle { currentStreamHandle?.let(::pstrAndroidStreamRelease); currentStreamHandle = null }
}
override fun releaseNativeStream(nativeHandle: ULong) = pstrAndroidStreamRelease(nativeHandle.toLong()) override fun releaseNativeStream(nativeHandle: ULong) = pstrAndroidStreamRelease(nativeHandle.toLong())
private fun startPolling() { private fun startPolling() {
if (poller?.isActive == true) return if (poller?.isActive == true) return
poller = scope.launch { // Off the main thread deliberately: `nativeState` and `nativeTracks`
// are synchronous `mpv_get_property` calls — five per track for the
// latter — that contend on the core lock the demuxer holds during a
// fetch. A poll that blocks there blocks the UI thread with it.
poller = scope.launch(Dispatchers.Default) {
var tick = 0 var tick = 0
while (isActive) { while (isActive) {
val values = withOpenHandle(::nativeState) ?: break val values = withOpenHandle(::nativeState) ?: break
if (values.size >= 6) { if (values.size >= FIELDS) {
val next = MpvPlaybackState(values[0], values[1], values[2], values[3] != 0.0, values[4] != 0.0, values[5] != 0.0) val next = MpvPlaybackState(
values[0], values[1], values[2],
values[3] != 0.0, values[4] != 0.0, values[5] != 0.0,
// Only a reading of the file this host was last asked
// to load carries its episode's name.
media = currentMedia.takeIf { values.generation() == currentGeneration },
videoWidth = values[WIDTH],
videoHeight = values[HEIGHT],
endReason = EndReason.of(values[END_REASON]),
buffering = values[BUFFERING] != 0.0,
cachePercent = values[CACHE_PERCENT],
seeking = values[SEEKING] != 0.0,
)
mutableState.value = next mutableState.value = next
onStateChanged?.invoke(next) // The listeners drive the media session and the player UI,
// both of which are the main thread's.
withContext(Dispatchers.Main) { onStateChanged?.invoke(next) }
refreshChapters(next)
} }
withOpenHandle(::nativeTakeError)?.let { mutableProblem.value = it }
if (tick++ % 4 == 0) refreshTracks() if (tick++ % 4 == 0) refreshTracks()
delay(250) delay(250)
} }
} }
} }
/**
* Re-read the chapters when there is a reason to: a different file, or a
* duration that has only just arrived. Both change the answer — the length
* of a chapter is what settles whether `Intro` is an opening or ten minutes
* of story — and neither changes again after that, so this settles quickly
* and then costs one comparison per poll.
*/
private fun refreshChapters(state: MpvPlaybackState) {
val generation = currentGeneration
val wanted = state.duration to generation.toRawBits()
if (plannedFor == wanted) return
runCatching {
val encoded = withOpenHandle(::nativeChapters) ?: return
val values = JSONArray(encoded)
List(values.length()) { index ->
values.getJSONObject(index).run {
ChapterRecord(
index = getLong("index"),
title = optString("title").takeIf(String::isNotBlank),
start = getDouble("start"),
)
}
}
}.onSuccess { chapters ->
plannedFor = wanted
mutableChapters.value = chapterPlan(chapters, state.duration.takeIf { it > 0.0 })
}
}
private fun refreshTracks() { private fun refreshTracks() {
runCatching { runCatching {
val encoded = withOpenHandle(::nativeTracks) ?: return val encoded = withOpenHandle(::nativeTracks) ?: return
@@ -149,16 +366,28 @@ class NativeMpvHost : LibmpvHost, AutoCloseable {
override fun close() { override fun close() {
scope.cancel() scope.cancel()
lifecycle.withLock { val destroy = lifecycle.withLock {
if (closed) return@withLock if (closed) return@withLock false
closed = true closed = true
onPlaybackStarted = null onPlaybackStarted = null
onStateChanged = null onStateChanged = null
onExplicitStop = null onExplicitStop = null
nativeDestroy(nativeHandle) onSkipPrevious = null
currentStreamHandle?.let(::pstrAndroidStreamRelease) onSkipNext = null
currentStreamHandle = null mutableNowPlaying.value = null
mutableAdvancing.value = false
true
} }
if (!destroy) return
// `mpv_terminate_destroy` joins mpv's demuxer thread, which may be
// parked in a block fetch over a degraded connection. This is called
// from Service.onDestroy — on the main thread — so waiting for it there
// is an ANR. `closed` is already set, so nothing new enters the handle;
// the lock is only to let a call that is already inside finish.
//
// Terminating the core closes the open file, and closing it releases the
// stream — after mpv's last read rather than during it.
Thread({ lifecycle.withLock { nativeDestroy(nativeHandle) } }, "pstr-mpv-teardown").start()
} }
/** Serializes destruction against every JNI use of the raw Player pointer. */ /** Serializes destruction against every JNI use of the raw Player pointer. */
@@ -166,7 +395,6 @@ class NativeMpvHost : LibmpvHost, AutoCloseable {
if (closed) null else block(nativeHandle) if (closed) null else block(nativeHandle)
} }
private external fun nativeCreate(): Long
private external fun nativeDestroy(handle: Long) private external fun nativeDestroy(handle: Long)
private external fun nativeAttachSurface(handle: Long, surface: Surface) private external fun nativeAttachSurface(handle: Long, surface: Surface)
private external fun nativeDetachSurface(handle: Long) private external fun nativeDetachSurface(handle: Long)
@@ -174,17 +402,47 @@ class NativeMpvHost : LibmpvHost, AutoCloseable {
private external fun nativePause(handle: Long, paused: Boolean) private external fun nativePause(handle: Long, paused: Boolean)
private external fun nativeSeek(handle: Long, position: Double) private external fun nativeSeek(handle: Long, position: Double)
private external fun nativeVolume(handle: Long, volume: Double) private external fun nativeVolume(handle: Long, volume: Double)
private external fun nativeSpeed(handle: Long, speed: Double)
private external fun nativeMute(handle: Long, muted: Boolean) private external fun nativeMute(handle: Long, muted: Boolean)
private external fun nativeSelectTrack(handle: Long, audio: Boolean, track: Long) private external fun nativeSelectTrack(handle: Long, audio: Boolean, track: Long)
private external fun nativeStop(handle: Long) private external fun nativeStop(handle: Long)
private external fun nativeState(handle: Long): DoubleArray private external fun nativeState(handle: Long): DoubleArray
private external fun nativeTracks(handle: Long): String private external fun nativeTakeError(handle: Long): String?
// Nullable: `NewStringUTF` returns null when the string cannot be built,
// and a non-null declaration turns that into an NPE that `runCatching`
// swallows into a silently empty track list.
private external fun nativeTracks(handle: Long): String?
private external fun nativeChapters(handle: Long): String?
private external fun pstrAndroidStreamRelease(handle: Long) private external fun pstrAndroidStreamRelease(handle: Long)
private fun DoubleArray.generation(): Double = getOrElse(GENERATION) { -1.0 }
companion object { companion object {
/** Fields `nativeState` fills, in the order `pstr_mpv.cpp` writes them. */
private const val FIELDS = 13
private const val GENERATION = 6
private const val WIDTH = 7
private const val HEIGHT = 8
private const val END_REASON = 9
private const val BUFFERING = 10
private const val CACHE_PERCENT = 11
private const val SEEKING = 12
init { init {
System.loadLibrary("pstr_android") System.loadLibrary("pstr_android")
System.loadLibrary("pstr_mpv") System.loadLibrary("pstr_mpv")
} }
/**
* A host over a live libmpv core, or null if libmpv would not start.
*
* Null rather than an exception: the service that owns this is created
* by the system, so a throw out of the constructor takes the process
* with it — and the app has a graceful "this build does not include
* libmpv" screen that a crash makes unreachable.
*/
fun createOrNull(): NativeMpvHost? = nativeCreate().takeIf { it != 0L }?.let(::NativeMpvHost)
@JvmStatic private external fun nativeCreate(): Long
} }
} }
@@ -5,7 +5,12 @@ import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.graphics.Bitmap
import android.graphics.drawable.Icon
import android.media.AudioAttributes import android.media.AudioAttributes
import android.media.AudioFocusRequest import android.media.AudioFocusRequest
import android.media.AudioManager import android.media.AudioManager
@@ -14,38 +19,99 @@ import android.media.session.MediaSession
import android.media.session.PlaybackState import android.media.session.PlaybackState
import android.os.Binder import android.os.Binder
import android.os.IBinder import android.os.IBinder
import androidx.core.content.ContextCompat
import io.narl.protonstream.MainActivity import io.narl.protonstream.MainActivity
import io.narl.protonstream.R
import io.narl.protonstream.settings.SettingsStore import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.ui.loadArtwork
import io.narl.protonstream.ui.loadThumbnail
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
/** Owns libmpv so audio survives Activity recreation, rotation and PiP. */ /** Owns libmpv so audio survives Activity recreation, rotation and PiP. */
class PlaybackService : Service() { class PlaybackService : Service() {
inner class PlaybackBinder : Binder() { inner class PlaybackBinder : Binder() {
val host: NativeMpvHost get() = this@PlaybackService.host val host: NativeMpvHost? get() = this@PlaybackService.host
} }
private lateinit var host: NativeMpvHost /**
* The libmpv core, or null when this build could not start one.
*
* Nullable rather than a `lateinit` that throws: the system creates this
* service, so a failure in its constructor takes the process down and makes
* the app's own "playback is unavailable" screen unreachable.
*/
private var host: NativeMpvHost? = null
private lateinit var mediaSession: MediaSession private lateinit var mediaSession: MediaSession
private lateinit var audioManager: AudioManager private lateinit var audioManager: AudioManager
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var audioFocus: AudioFocusRequest? = null private var audioFocus: AudioFocusRequest? = null
private var notifiedPaused: Boolean? = null /** Whether the pause on screen is this service's doing, not the viewer's. */
private var pausedForFocus = false
/** The volume before ducking, so the restore does not read a ducked one. */
private var beforeDucking: Double? = null
/**
* Pulling the headphone jack must not continue on the speaker.
*
* Android broadcasts this and expects every media app to handle it; the
* alternative is an episode playing out loud in a quiet room.
*/
private val becomingNoisy = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) host?.setPaused(true)
}
}
private var foregroundPlayback = false private var foregroundPlayback = false
private var sessionPlayback = false private var sessionPlayback = false
/**
* What the posted notification already says.
*
* The state poll runs four times a second and almost none of those readings
* change anything the shade shows. Re-posting on every one of them animates
* the media control and costs a binder round trip per tick, so the
* notification is rebuilt only when its own inputs move.
*/
private var notified: NotificationShape? = null
/** The pending teardown, held so an episode transition can call it off. */
private var retire: Job? = null
/** The episode's artwork, and the source key it was fetched for. */
private var artwork: Pair<String, Bitmap>? = null
private val binder = PlaybackBinder() private val binder = PlaybackBinder()
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
createChannel() createChannel()
ContextCompat.registerReceiver(
this,
becomingNoisy,
IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
audioManager = getSystemService(AudioManager::class.java) audioManager = getSystemService(AudioManager::class.java)
host = NativeMpvHost().apply { host = NativeMpvHost.createOrNull()?.apply {
onPlaybackStarted = { onPlaybackStarted = {
requestAudioFocus() // A refusal is rare — a call in progress — but playing over it
// is worse than not playing, and the viewer can simply press
// play again once the call ends.
if (!requestAudioFocus()) setPaused(true)
sessionPlayback = true sessionPlayback = true
if (SettingsStore(this@PlaybackService).backgroundAudio) { cancelRetire()
if (!foregroundPlayback && SettingsStore(this@PlaybackService).backgroundAudio) {
// Convert the bound service into a started foreground // Convert the bound service into a started foreground
// service only when the viewer opted to keep audio alive. // service only when the viewer opted to keep audio alive.
startService(Intent(this@PlaybackService, PlaybackService::class.java)) // Already-foreground is the ordinary case across an episode
startForeground(NOTIFICATION_ID, notification(false)) // transition, and re-entering costs a notification rebuild.
foregroundPlayback = true foregroundPlayback = runCatching {
startService(Intent(this@PlaybackService, PlaybackService::class.java))
startForeground(NOTIFICATION_ID, notification(state.value, nowPlaying.value))
}.isSuccess
} }
mediaSession.isActive = true mediaSession.isActive = true
} }
@@ -53,80 +119,213 @@ class PlaybackService : Service() {
onExplicitStop = { retirePlayback() } onExplicitStop = { retirePlayback() }
} }
mediaSession = MediaSession(this, "proton-stream").apply { mediaSession = MediaSession(this, "proton-stream").apply {
setSessionActivity(openActivity())
setCallback(object : MediaSession.Callback() { setCallback(object : MediaSession.Callback() {
override fun onPlay() = host.setPaused(false) override fun onPlay() { host?.setPaused(false) }
override fun onPause() = host.setPaused(true) override fun onPause() { host?.setPaused(true) }
override fun onSeekTo(pos: Long) = host.seek(pos / 1_000.0) override fun onSeekTo(pos: Long) { host?.seek(pos / 1_000.0) }
override fun onStop() = host.stop() override fun onStop() { host?.stop() }
override fun onSkipToNext() { host?.onSkipNext?.invoke() }
override fun onSkipToPrevious() { host?.onSkipPrevious?.invoke() }
}) })
} }
// Artwork arrives after the episode does, and the shape of the transport
// changes with the playlist position, so the notification follows the
// published episode rather than being built once when playback starts.
val core = host ?: return
scope.launch {
core.nowPlaying.collect { playing ->
// Keyed by whichever source the episode has: with metadata off
// there is no provider poster, and Proton's own thumbnail is
// the only thing the shade can show.
artwork = playing?.let { episode ->
val key = episode.artworkUrl ?: episode.artworkFile?.key ?: return@let null
artwork?.takeIf { it.first == key }
?: (
loadArtwork(this@PlaybackService, episode.artworkUrl)
?: loadThumbnail(episode.artworkFile)
)?.let { key to it }
}
refreshSession(core.state.value, playing)
}
}
} }
override fun onBind(intent: Intent?): IBinder = binder override fun onBind(intent: Intent?): IBinder = binder
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) { when (intent?.action) {
ACTION_PLAY -> host.setPaused(false) ACTION_PLAY -> host?.setPaused(false)
ACTION_PAUSE -> host.setPaused(true) ACTION_PAUSE -> host?.setPaused(true)
ACTION_STOP -> host.stop() ACTION_PREVIOUS -> host?.onSkipPrevious?.invoke()
ACTION_NEXT -> host?.onSkipNext?.invoke()
ACTION_STOP -> host?.stop()
} }
return START_NOT_STICKY return START_NOT_STICKY
} }
override fun onDestroy() { override fun onDestroy() {
scope.cancel()
runCatching { unregisterReceiver(becomingNoisy) }
audioFocus?.let(audioManager::abandonAudioFocusRequest) audioFocus?.let(audioManager::abandonAudioFocusRequest)
mediaSession.release() mediaSession.release()
host.close() host?.close()
super.onDestroy() super.onDestroy()
} }
private fun requestAudioFocus() { /**
* Take audio focus, and honour what the system says about it afterwards.
*
* The four cases are genuinely different and collapsing them is what made
* this feel broken: a phone call is transient and playback should come back
* by itself; a navigation prompt wants the volume down, not a pause; another
* media app taking over for good means giving the focus back rather than
* holding it; and a refusal means not playing at all.
*/
private fun requestAudioFocus(): Boolean {
audioFocus?.let(audioManager::abandonAudioFocusRequest) audioFocus?.let(audioManager::abandonAudioFocusRequest)
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes( .setAudioAttributes(
AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA) AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build(), .setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build(),
) )
.setWillPauseWhenDucked(false)
.setOnAudioFocusChangeListener { focus -> .setOnAudioFocusChangeListener { focus ->
when (focus) { when (focus) {
AudioManager.AUDIOFOCUS_LOSS, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> host.setPaused(true) AudioManager.AUDIOFOCUS_LOSS -> {
AudioManager.AUDIOFOCUS_GAIN -> Unit pausedForFocus = false
audioFocus?.let(audioManager::abandonAudioFocusRequest)
audioFocus = null
host?.setPaused(true)
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
// Only resume what this paused: a viewer who paused
// before the phone rang expects it still paused after.
pausedForFocus = host?.state?.value?.paused == false
host?.setPaused(true)
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
host?.let { core ->
val current = core.state.value.volume
beforeDucking = beforeDucking ?: current
core.setVolume(current * DUCK_FACTOR)
}
}
AudioManager.AUDIOFOCUS_GAIN -> {
beforeDucking?.let { host?.setVolume(it) }
beforeDucking = null
if (pausedForFocus) {
pausedForFocus = false
host?.setPaused(false)
}
}
} }
}.build() }.build()
audioFocus = request audioFocus = request
audioManager.requestAudioFocus(request) return audioManager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
} }
private fun updateSession(state: MpvPlaybackState) { private fun updateSession(state: MpvPlaybackState) {
refreshSession(state, host?.nowPlaying?.value)
if (state.ended) scheduleRetire() else cancelRetire()
}
/**
* Retire the session once playback is really over.
*
* "This file ended" is not "playback is over": autoplay walks to the next
* episode, and opening its stream is a network round trip. Retiring inside
* that gap leaves the app with no foreground state at all, so the
* `startForeground` on the far side is a background start — which API 31+
* refuses with `ForegroundServiceStartNotAllowedException`, and this app's
* minSdk is 31.
*/
private fun scheduleRetire() {
if (retire?.isActive == true) return
retire = scope.launch {
// The end of file reaches the player and this service from the same
// poll, and the player's advance is a recomposition behind. Give it
// that long to declare a transition before concluding there is none.
delay(TRANSITION_SETTLE_MS)
val host = this@PlaybackService.host ?: return@launch
withTimeoutOrNull(TRANSITION_GRACE_MS) { host.advancing.first { !it } }
if (host.state.value.ended) retirePlayback()
}
}
private fun cancelRetire() {
retire?.cancel()
retire = null
}
private fun refreshSession(state: MpvPlaybackState, playing: NowPlaying?) {
val status = when { val status = when {
state.ended -> PlaybackState.STATE_STOPPED state.ended -> PlaybackState.STATE_STOPPED
state.paused -> PlaybackState.STATE_PAUSED state.paused -> PlaybackState.STATE_PAUSED
else -> PlaybackState.STATE_PLAYING else -> PlaybackState.STATE_PLAYING
} }
var actions = PlaybackState.ACTION_PLAY or PlaybackState.ACTION_PAUSE or
PlaybackState.ACTION_PLAY_PAUSE or PlaybackState.ACTION_SEEK_TO or PlaybackState.ACTION_STOP
if (playing?.hasNext == true) actions = actions or PlaybackState.ACTION_SKIP_TO_NEXT
if (playing?.hasPrevious == true) actions = actions or PlaybackState.ACTION_SKIP_TO_PREVIOUS
mediaSession.setPlaybackState( mediaSession.setPlaybackState(
PlaybackState.Builder() PlaybackState.Builder()
.setActions(PlaybackState.ACTION_PLAY or PlaybackState.ACTION_PAUSE or PlaybackState.ACTION_SEEK_TO or PlaybackState.ACTION_STOP) .setActions(actions)
.setState(status, (state.position * 1_000).toLong(), if (state.paused) 0f else 1f) .setState(status, (state.position * 1_000).toLong(), if (state.paused) 0f else 1f)
.build(), .build(),
) )
mediaSession.setMetadata( mediaSession.setMetadata(metadata(state, playing))
MediaMetadata.Builder().putLong(MediaMetadata.METADATA_KEY_DURATION, (state.duration * 1_000).toLong()).build(), val shape = NotificationShape(
paused = state.paused,
media = playing?.media,
title = playing?.title,
subtitle = playing?.show,
hasPrevious = playing?.hasPrevious == true,
hasNext = playing?.hasNext == true,
hasArtwork = artwork != null,
) )
if (state.ended && foregroundPlayback) { if (foregroundPlayback && notified != shape) {
host.releaseEndedStream() notified = shape
retirePlayback() getSystemService(NotificationManager::class.java)
return .notify(NOTIFICATION_ID, notification(state, playing))
}
if (foregroundPlayback && notifiedPaused != state.paused) {
notifiedPaused = state.paused
getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(state.paused))
} }
} }
/**
* What the shade, the lock screen and Android Auto read.
*
* The episode is the title and the show is the artist, which is the mapping
* every media surface already lays out well: one prominent line, one
* secondary, and the poster behind both.
*/
private fun metadata(state: MpvPlaybackState, playing: NowPlaying?): MediaMetadata =
MediaMetadata.Builder()
.putString(MediaMetadata.METADATA_KEY_TITLE, playing?.title ?: "proton-stream")
.putString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE, playing?.title ?: "proton-stream")
.apply {
playing?.show?.let {
putString(MediaMetadata.METADATA_KEY_ARTIST, it)
putString(MediaMetadata.METADATA_KEY_ALBUM, it)
putString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE, it)
}
playing?.detail?.let {
putString(MediaMetadata.METADATA_KEY_DISPLAY_DESCRIPTION, it)
}
artwork?.second?.let {
putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, it)
putBitmap(MediaMetadata.METADATA_KEY_ART, it)
putBitmap(MediaMetadata.METADATA_KEY_DISPLAY_ICON, it)
}
}
.putLong(MediaMetadata.METADATA_KEY_DURATION, (state.duration * 1_000).toLong())
.build()
private fun retirePlayback() { private fun retirePlayback() {
cancelRetire()
if (!sessionPlayback && !foregroundPlayback) return if (!sessionPlayback && !foregroundPlayback) return
sessionPlayback = false sessionPlayback = false
notifiedPaused = null notified = null
artwork = null
mediaSession.isActive = false mediaSession.isActive = false
audioFocus?.let(audioManager::abandonAudioFocusRequest) audioFocus?.let(audioManager::abandonAudioFocusRequest)
audioFocus = null audioFocus = null
@@ -135,40 +334,98 @@ class PlaybackService : Service() {
stopSelf() stopSelf()
} }
private fun notification(paused: Boolean): Notification { private fun openActivity(): PendingIntent = PendingIntent.getActivity(
val open = PendingIntent.getActivity( this,
this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, 0,
) Intent(this, MainActivity::class.java),
val toggleAction = if (paused) ACTION_PLAY else ACTION_PAUSE PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
val toggle = PendingIntent.getService( )
this, 1, Intent(this, PlaybackService::class.java).setAction(toggleAction), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
) private fun command(requestCode: Int, action: String): PendingIntent = PendingIntent.getService(
val stop = PendingIntent.getService( this,
this, 2, Intent(this, PlaybackService::class.java).setAction(ACTION_STOP), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, requestCode,
) Intent(this, PlaybackService::class.java).setAction(action),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
private fun action(icon: Int, label: String, requestCode: Int, name: String): Notification.Action =
Notification.Action.Builder(Icon.createWithResource(this, icon), label, command(requestCode, name)).build()
private fun notification(state: MpvPlaybackState, playing: NowPlaying?): Notification {
val paused = state.paused
val actions = buildList {
if (playing?.hasPrevious == true) {
add(action(android.R.drawable.ic_media_previous, "Previous", 3, ACTION_PREVIOUS))
}
add(
if (paused) action(android.R.drawable.ic_media_play, "Play", 1, ACTION_PLAY)
else action(android.R.drawable.ic_media_pause, "Pause", 1, ACTION_PAUSE),
)
if (playing?.hasNext == true) {
add(action(android.R.drawable.ic_media_next, "Next", 4, ACTION_NEXT))
}
add(action(android.R.drawable.ic_menu_close_clear_cancel, "Stop", 2, ACTION_STOP))
}
val style = Notification.MediaStyle()
.setMediaSession(mediaSession.sessionToken)
// The compact view holds three: the transport as it exists for this
// episode, so a first episode does not show a dead Previous.
.setShowActionsInCompactView(*IntArray(minOf(3, actions.size)) { it })
return Notification.Builder(this, CHANNEL_ID) return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_media_play) .setSmallIcon(R.drawable.ic_notification_stream)
.setContentTitle("proton-stream") .setContentTitle(playing?.title ?: "proton-stream")
.setContentText(if (paused) "Playback paused" else "Playing") .setContentText(playing?.show ?: playing?.detail ?: if (paused) "Paused" else "Playing")
.setContentIntent(open) .apply {
playing?.takeIf { it.show != null }?.detail?.let { setSubText(it) }
artwork?.second?.let { setLargeIcon(it) }
}
.setColor(ACCENT)
.setColorized(true)
.setContentIntent(openActivity())
.setDeleteIntent(command(2, ACTION_STOP))
.setVisibility(Notification.VISIBILITY_PUBLIC)
.setOngoing(!paused) .setOngoing(!paused)
.addAction(Notification.Action.Builder(null, if (paused) "Play" else "Pause", toggle).build()) .apply { actions.forEach { addAction(it) } }
.addAction(Notification.Action.Builder(null, "Stop", stop).build()) .setStyle(style)
.setStyle(Notification.MediaStyle().setMediaSession(mediaSession.sessionToken).setShowActionsInCompactView(0, 1))
.build() .build()
} }
private fun createChannel() { private fun createChannel() {
getSystemService(NotificationManager::class.java).createNotificationChannel( getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Playback", NotificationManager.IMPORTANCE_LOW), NotificationChannel(CHANNEL_ID, "Playback", NotificationManager.IMPORTANCE_LOW).apply {
description = "Transport controls for the episode being played."
setShowBadge(false)
},
) )
} }
/** The inputs the posted notification is built from, and nothing else. */
private data class NotificationShape(
val paused: Boolean,
val media: String?,
val title: String?,
val subtitle: String?,
val hasPrevious: Boolean,
val hasNext: Boolean,
val hasArtwork: Boolean,
)
companion object { companion object {
private const val CHANNEL_ID = "playback" private const val CHANNEL_ID = "playback"
private const val NOTIFICATION_ID = 47 private const val NOTIFICATION_ID = 47
/** How long a player gets to declare that it is changing episodes. */
private const val TRANSITION_SETTLE_MS = 1_000L
/** And how long that transition may then take before it is abandoned. */
private const val TRANSITION_GRACE_MS = 30_000L
/** How far the volume drops for a navigation prompt or a notification. */
private const val DUCK_FACTOR = 0.3
/** Catppuccin Mocha mauve, the accent the app is themed with. */
private const val ACCENT = 0xFFCBA6F7.toInt()
private const val ACTION_PLAY = "io.narl.protonstream.PLAY" private const val ACTION_PLAY = "io.narl.protonstream.PLAY"
private const val ACTION_PAUSE = "io.narl.protonstream.PAUSE" private const val ACTION_PAUSE = "io.narl.protonstream.PAUSE"
private const val ACTION_PREVIOUS = "io.narl.protonstream.PREVIOUS"
private const val ACTION_NEXT = "io.narl.protonstream.NEXT"
private const val ACTION_STOP = "io.narl.protonstream.STOP" private const val ACTION_STOP = "io.narl.protonstream.STOP"
} }
} }
@@ -0,0 +1,974 @@
package io.narl.protonstream.playback
import android.app.Activity
import android.content.pm.ActivityInfo
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.WindowManager
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Toc
import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Audiotrack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.Forward30
import androidx.compose.material.icons.filled.Fullscreen
import androidx.compose.material.icons.filled.FullscreenExit
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Replay10
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.material.icons.filled.Subtitles
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.ui.thumbnailSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import uniffi.pstr_android.ChapterEntry
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TrackPreferencesRecord
import uniffi.pstr_android.skipOffer
import kotlin.math.roundToInt
/** How long the controls stay up after a tap. */
private const val CONTROLS_SECONDS = 4L
/** How long the "up next" card counts down before it loads the next episode. */
private const val UP_NEXT_SECONDS = 10
/** Past this much of an episode it counts as watched. */
private const val WATCHED_FRACTION = 0.9
/** The loudest this app plays. Matches `pstr_core::prefs`, which stores it. */
private const val MAX_VOLUME = 100f
/**
* The narrowest layout the in-app volume slider is worth the room it costs.
*
* Below it — a phone held upright — the transport row is already five controls
* wide, and the device's own volume keys are the better answer.
*/
private val VOLUME_SLIDER_MIN_WIDTH = 600.dp
/**
* The player, as its own screen on the activity's own window.
*
* Not a dialog: a dialog window has its own insets, cannot be told to draw into
* the display cutout, and hands Picture-in-Picture the wrong window. Owning the
* activity window is what makes "fullscreen" mean the whole panel, notch
* included.
*
* [episodes] is the title's episodes in display order and [index] the one that is
* open, which is what previous/next and autoplay walk. The index is hoisted so
* that it survives process recreation with the rest of the navigation state —
* this screen is destroyed and rebuilt by a dark-mode toggle like any other.
*
* In [inPictureInPicture] the window is a thumbnail with the system's own
* controls over it, so this screen draws the picture and nothing else.
*/
@Composable
fun PlayerScreen(
title: TitleRecord,
episodes: List<EpisodeRecord>,
index: Int,
host: LibmpvHost?,
inPictureInPicture: Boolean,
minimized: Boolean,
onIndexChange: (Int) -> Unit,
onSaveProgress: (EpisodeRecord, Double, Double, Boolean) -> Unit,
onMinimize: () -> Unit,
onClose: () -> Unit,
) {
val context = LocalContext.current
val activity = context as? Activity
val titleKey = title.key
val episode = episodes.getOrNull(index)
// Minimised, this screen keeps the stream and the effects that own it, and
// draws nothing: no surface — mpv plays on without one, exactly as it does
// while the app is in the background — no immersive window, and no back
// handler, because back belongs to the page the viewer went to.
if (!minimized) {
ImmersiveWindow(activity)
// Back leaves the video rather than ending it. Stopping is the mini
// transport's ✕, which is the one place it is unambiguous.
BackHandler(onBack = onMinimize)
}
// The transport reads state only this host publishes, so the interface
// alone is not enough to draw a player.
val nativeHost = host as? NativeMpvHost
if (nativeHost == null || episode == null) {
if (minimized) return
Box(Modifier.fillMaxSize().background(Color.Black), contentAlignment = Alignment.Center) {
Text(
if (nativeHost == null) "Playback is unavailable: this build does not include libmpv."
else "This title has no episode to play.",
color = Color.White,
)
}
return
}
val key = mediaKey(episode)
val session = remember(key) { RustStreamSession(episode) }
var playbackError by remember(key) { mutableStateOf<String?>(null) }
LaunchedEffect(session, host) {
runCatching {
val startup = withContext(Dispatchers.IO) {
session.open()
val engine = NativeRuntime.engine()
// A choice made on this title wins; without one, the choice the
// viewer made anywhere else does. Both are the desktop client's
// fallback chain, and it is what makes "Japanese with English
// subtitles" a thing you say once rather than once per show.
val show = engine.titleTrackPreferences(titleKey)
val global = engine.playbackPrefs()
val watch = engine.watchState(episode.shareId, episode.linkId)
Startup(
nativeHandle = session.nativeHandle(),
size = session.size(),
// A finished episode restarts rather than resuming three
// seconds from its own credits.
startPosition = watch?.takeUnless { it.watched }?.positionSecs ?: 0.0,
audioLanguage = show?.audioLanguage ?: global.audioLanguage,
subtitleLanguage = show?.subtitleLanguage ?: global.subtitleLanguage,
subtitles = show?.subtitles ?: global.subtitles,
volume = global.volume,
muted = global.muted,
speed = global.speed,
)
}
nativeHost.play(
key, startup.nativeHandle, startup.size, startup.startPosition,
startup.audioLanguage, startup.subtitleLanguage, startup.subtitles,
)
// After the load, because mpv keeps none of the three across one.
nativeHost.setVolume(startup.volume)
nativeHost.setMuted(startup.muted)
nativeHost.setSpeed(startup.speed)
session.transferToPlayer()
}.onFailure { playbackError = it.message ?: "Playback failed" }
}
// Per episode: the stream Rust holds open. The surface is the screen's, not
// this episode's, so detaching it belongs to the effect below and not here.
DisposableEffect(session) { onDispose { session.close(nativeHost) } }
DisposableEffect(nativeHost) { onDispose { nativeHost.detachSurface() } }
val state by nativeHost.state.collectAsState()
val chapters by nativeHost.chapters.collectAsState()
val tracks by nativeHost.tracks.collectAsState()
// Read once: a preference the viewer changes mid-episode applies to the
// next one, and re-reading the store per frame would not.
var autoSkip by remember { mutableStateOf(false) }
var autoplay by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().playbackPrefs() } }
.onSuccess { autoSkip = it.autoSkip; autoplay = it.autoplayNext }
}
// Resume position, written only from readings that carry this episode's
// name: one host serves every episode, and an unattributed reading is the
// previous one's clock.
fun save() {
val current = nativeHost.state.value
if (current.media == key && current.duration > 0.0) {
onSaveProgress(
episode,
current.position.coerceIn(0.0, current.duration),
current.duration,
// Watched means played to the end. An episode that was stopped,
// or that mpv could not play at all, also "ended" — recording
// either as watched retires an episode nobody saw.
current.endReason == EndReason.Eof ||
current.position >= current.duration * WATCHED_FRACTION,
)
}
}
LaunchedEffect(key) {
while (true) {
delay(5_000)
save()
}
}
DisposableEffect(key) { onDispose { save() } }
val next = episodes.getOrNull(index + 1)
// Both walks tell the host they are one, so the foreground service holds
// through the gap where nothing is loaded — see NativeMpvHost.advancing.
fun advance() {
save()
next?.let { nativeHost.beginTransition(); onIndexChange(index + 1) }
}
fun retreat() {
if (index == 0) return
save()
nativeHost.beginTransition()
onIndexChange(index - 1)
}
// What the media notification, the lock screen and Picture-in-Picture show.
// None of them can reach this composition, and all three have to name the
// episode rather than the app.
LaunchedEffect(key, index, episodes.size, title.key) {
nativeHost.publish(
NowPlaying(
media = key,
title = episode.label,
show = title.canonicalName ?: title.name,
detail = episode.detail,
artworkUrl = title.posterUrl ?: title.backdropUrl,
artworkFile = episode.thumbnailSource,
hasPrevious = index > 0,
hasNext = next != null,
),
)
}
// Those same surfaces walk the playlist through here, because this is where
// the open episode lives. Both callbacks run on the main thread.
DisposableEffect(nativeHost, index, episodes.size) {
nativeHost.onSkipNext = { advance() }
nativeHost.onSkipPrevious = { retreat() }
onDispose {
nativeHost.onSkipNext = null
nativeHost.onSkipPrevious = null
}
}
// Autoplay fires on a clean end of file only, and only for this episode's
// own reading — never on a failure, and never on the outgoing file's.
LaunchedEffect(state.endReason, state.media) {
if (state.endReason == EndReason.Eof && state.media == key && autoplay && next != null) advance()
}
// A file mpv could not play ends like any other. Saying so is the whole
// difference between a broken episode and an app that silently skipped one.
LaunchedEffect(state.endReason, state.media) {
if (state.endReason == EndReason.Failed && state.media == key) {
playbackError = "This episode could not be played."
}
}
// What mpv itself complained about, which the play call never sees: by the
// time a header turns out to be unreadable it has long since returned.
val problem by nativeHost.problem.collectAsState()
LaunchedEffect(problem) { problem?.let { playbackError = it } }
val offer = remember(chapters, key, (state.position * 4).roundToInt()) {
runCatching { skipOffer(chapters, state.position) }.getOrNull()
}
// Auto-skip is offered once per chapter: seeking back into an opening on
// purpose must not be undone by the setting that skipped it.
val skipped = remember(key) { mutableSetOf<Long>() }
LaunchedEffect(offer?.target) {
val target = offer?.target ?: return@LaunchedEffect
val chapter = chapters.entries.lastOrNull { state.position + 0.001 >= it.start } ?: return@LaunchedEffect
if (autoSkip && !state.paused && skipped.add(chapter.index)) nativeHost.seek(target)
}
var landscapeLocked by remember { mutableStateOf(false) }
var showControls by remember { mutableStateOf(true) }
LaunchedEffect(showControls, state.paused) {
if (showControls && !state.paused) {
delay(CONTROLS_SECONDS * 1_000)
showControls = false
}
}
// Everything this screen draws over the picture is sized for a phone panel
// and unreadable in a thumbnail, and Android supplies its own transport
// there. Leaving PiP must find the controls up rather than mid-timeout.
val overlays = !inPictureInPicture
LaunchedEffect(inPictureInPicture) { if (!inPictureInPicture) showControls = true }
if (minimized) return
Box(
Modifier
.fillMaxSize()
.background(Color.Black)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
enabled = overlays,
onClick = { showControls = !showControls },
),
) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { viewContext ->
SurfaceView(viewContext).apply {
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) = host.attachSurface(holder.surface)
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) =
host.attachSurface(holder.surface)
override fun surfaceDestroyed(holder: SurfaceHolder) = host.detachSurface()
})
}
},
)
AnimatedVisibility(
visible = overlays && showControls,
modifier = Modifier.align(Alignment.TopStart),
enter = fadeIn(),
exit = fadeOut(),
) {
Row(
Modifier
.fillMaxWidth()
.background(Color.Black.copy(alpha = 0.45f))
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onMinimize) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Leave the video",
tint = Color.White,
)
}
// The episode leads and the show follows it: which episode is
// the thing a viewer who has just woken the controls is
// checking, and the show is what they already know.
Column(Modifier.weight(1f)) {
Text(
episode.label,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleMedium,
)
Text(
listOfNotNull(title.canonicalName ?: title.name, episode.detail.takeIf(String::isNotBlank))
.joinToString(" • "),
color = Color.White.copy(alpha = 0.7f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodySmall,
)
}
// Two ways out, because they mean different things: back leaves
// the video and keeps the episode playing, this ends it.
IconButton(onClick = onClose) {
Icon(Icons.Default.Close, contentDescription = "Stop playback", tint = Color.White)
}
remaining(state)?.let {
Text(
it,
color = Color.White.copy(alpha = 0.7f),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(start = 12.dp, end = 4.dp),
)
}
}
}
AnimatedVisibility(
visible = overlays && showControls,
modifier = Modifier.align(Alignment.BottomCenter),
enter = fadeIn(),
exit = fadeOut(),
) {
PlayerControls(
titleKey = titleKey,
host = nativeHost,
state = state,
chapters = chapters.entries,
tracks = tracks,
hasPrevious = index > 0,
hasNext = next != null,
onPrevious = { retreat() },
onNext = { advance() },
onRotate = { landscapeLocked = activity?.toggleLandscape() ?: false },
landscapeLocked = landscapeLocked,
)
}
// Drawn whether or not the rest of the controls are up: an opening lasts
// ninety seconds, and a button you have to wake with a tap is one nobody
// reaches in time.
offer?.takeIf { overlays && !autoSkip }?.let { skip ->
FilledTonalButton(
onClick = { nativeHost.seek(skip.target) },
modifier = Modifier
.align(Alignment.BottomEnd)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(end = 16.dp, bottom = if (showControls) 132.dp else 24.dp),
) { Text(skip.label) }
}
UpNextCard(
next = next,
visible = overlays && autoplay && !state.paused &&
chapters.creditsStart?.let { state.position >= it } == true,
onPlayNow = { advance() },
modifier = Modifier
.align(Alignment.BottomEnd)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(end = 16.dp, bottom = if (showControls) 132.dp else 24.dp),
)
// Only while nothing else explains the still picture: an error is the
// more useful thing to say, and a spinner over it reads as "still
// trying".
if (state.stalled && playbackError == null) {
Column(
Modifier
.align(Alignment.Center)
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(12.dp))
.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator(color = Color.White, modifier = Modifier.size(36.dp))
if (state.buffering) {
Text(
state.cachePercent.takeIf { it > 0.0 }
?.let { "Buffering… ${it.roundToInt()}%" } ?: "Buffering…",
color = Color.White,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(top = 12.dp),
)
}
}
}
playbackError?.let { message ->
Column(
Modifier
.align(Alignment.Center)
.background(Color.Black.copy(alpha = 0.85f), RoundedCornerShape(12.dp))
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(message, color = Color.White)
// Dismissible, because the message is not always fatal — a
// subtitle track that failed to load leaves an episode that
// plays perfectly well behind an error that used to be permanent.
TextButton(onClick = { playbackError = null; nativeHost.clearProblem() }) {
Text("Dismiss", color = Color.White)
}
}
}
}
}
private data class Startup(
val nativeHandle: ULong,
val size: ULong,
val startPosition: Double,
val audioLanguage: String?,
val subtitleLanguage: String?,
val subtitles: Boolean,
val volume: Double,
val muted: Boolean,
val speed: Double,
)
/**
* Immersive for as long as this screen is up, and only that long.
*
* Hiding the bars is not enough on its own: without `setDecorFitsSystemWindows`
* and a cutout mode the picture stops at the notch and at the gesture bar, which
* is the "not really fullscreen" everyone notices.
*/
@Composable
private fun ImmersiveWindow(activity: Activity?) {
DisposableEffect(activity) {
val window = activity?.window
if (window == null) return@DisposableEffect onDispose {}
val controller = WindowInsetsControllerCompat(window, window.decorView)
val previousCutout = window.attributes.layoutInDisplayCutoutMode
WindowCompat.setDecorFitsSystemWindows(window, false)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
window.attributes = window.attributes.also {
it.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
controller.hide(WindowInsetsCompat.Type.systemBars())
controller.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
onDispose {
controller.show(WindowInsetsCompat.Type.systemBars())
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
window.attributes = window.attributes.also { it.layoutInDisplayCutoutMode = previousCutout }
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
}
}
/** Lock to landscape, or hand rotation back to the sensor. Reports which. */
private fun Activity.toggleLandscape(): Boolean {
val lock = requestedOrientation != ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
requestedOrientation = if (lock) ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
else ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
return lock
}
/**
* Everything the transport offers, in two rows.
*
* A phone in landscape has one thumb's worth of room, so the second row is
* icons: the transport cluster on the left, where a thumb already is, and the
* pickers on the right. Only the seek bar gets a full row of its own.
*/
@Composable
private fun PlayerControls(
titleKey: String,
host: NativeMpvHost,
state: MpvPlaybackState,
chapters: List<ChapterEntry>,
tracks: List<MpvTrack>,
hasPrevious: Boolean,
hasNext: Boolean,
onPrevious: () -> Unit,
onNext: () -> Unit,
onRotate: () -> Unit,
landscapeLocked: Boolean,
) {
val scope = rememberCoroutineScope()
var choosing by remember { mutableStateOf<String?>(null) }
var showChapters by remember { mutableStateOf(false) }
var showSpeeds by remember { mutableStateOf(false) }
// What the store says, so the dialog opens on the rate that is playing.
var speed by remember { mutableStateOf(1.0) }
LaunchedEffect(Unit) {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().playbackPrefs() } }
.onSuccess { speed = it.speed }
}
val duration = state.duration.toFloat().coerceAtLeast(1f)
val density = LocalDensity.current
val roomForVolume = with(density) {
LocalWindowInfo.current.containerSize.width.toDp() >= VOLUME_SLIDER_MIN_WIDTH
}
Column(
Modifier
.fillMaxWidth()
.background(Color.Black.copy(alpha = 0.68f))
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 12.dp, vertical = 4.dp),
) {
// Where the thumb is while a finger is on it. A seek per touch-move
// aborts every outstanding prefetch, so a one-second drag used to issue
// dozens of seeks and throw away every in-flight block — and the thumb
// snapped backwards under the finger, because it was drawn from a
// position that only updates four times a second.
var scrubbing by remember { mutableStateOf<Float?>(null) }
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
formatTime(scrubbing?.toDouble() ?: state.position),
color = Color.White,
style = MaterialTheme.typography.labelMedium,
)
Box(Modifier.weight(1f)) {
Slider(
value = scrubbing ?: state.position.toFloat().coerceIn(0f, duration),
onValueChange = { scrubbing = it },
onValueChangeFinished = {
scrubbing?.let { host.seek(it.toDouble()) }
scrubbing = null
},
valueRange = 0f..duration,
)
ChapterMarks(chapters, state.duration, Modifier.matchParentSize())
}
Text(formatTime(state.duration), color = Color.White, style = MaterialTheme.typography.labelMedium)
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
CompactIcon(Icons.Default.SkipPrevious, "Previous episode", enabled = hasPrevious, onClick = onPrevious)
CompactIcon(Icons.Default.Replay10, "Back ten seconds") { host.seek(state.position - 10) }
IconButton(onClick = { host.setPaused(!state.paused) }) {
Icon(
if (state.paused) Icons.Default.PlayArrow else Icons.Default.Pause,
contentDescription = if (state.paused) "Play" else "Pause",
tint = Color.White,
modifier = Modifier.size(32.dp),
)
}
CompactIcon(Icons.Default.Forward30, "Forward thirty seconds") { host.seek(state.position + 30) }
CompactIcon(Icons.Default.SkipNext, "Next episode", enabled = hasNext, onClick = onNext)
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (chapters.size > 1) {
CompactIcon(Icons.AutoMirrored.Filled.Toc, "Chapters") { showChapters = true }
}
CompactIcon(Icons.Default.Speed, "Playback speed") { showSpeeds = true }
CompactIcon(Icons.Default.Audiotrack, "Audio track") { choosing = "audio" }
CompactIcon(Icons.Default.Subtitles, "Subtitles") { choosing = "sub" }
CompactIcon(
if (state.muted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp,
"Mute",
) {
host.setMuted(!state.muted)
scope.launch(Dispatchers.IO) { rememberVolume(muted = !state.muted) }
}
// The app's own volume, which is not the device's: a file
// mastered quiet is turned up here and stays up for the next
// one. Only where there is room for it — a phone in portrait
// has the system volume keys and no width to spare.
if (roomForVolume) {
var dragging by remember { mutableStateOf<Float?>(null) }
Slider(
value = dragging ?: state.volume.toFloat().coerceIn(0f, MAX_VOLUME),
onValueChange = { dragging = it; host.setVolume(it.toDouble()) },
onValueChangeFinished = {
val chosen = dragging?.toDouble()
dragging = null
chosen?.let { scope.launch(Dispatchers.IO) { rememberVolume(volume = it) } }
},
valueRange = 0f..MAX_VOLUME,
modifier = Modifier.width(96.dp),
)
}
CompactIcon(
if (landscapeLocked) Icons.Default.FullscreenExit else Icons.Default.Fullscreen,
if (landscapeLocked) "Unlock rotation" else "Lock to landscape",
onClick = onRotate,
)
}
}
}
if (showSpeeds) {
SpeedChooser(
current = speed,
onDismiss = { showSpeeds = false },
onSelect = { chosen ->
speed = chosen
host.setSpeed(chosen)
showSpeeds = false
scope.launch(Dispatchers.IO) { rememberSpeed(chosen) }
},
)
}
if (showChapters) {
ChapterChooser(
chapters = chapters,
position = state.position,
onDismiss = { showChapters = false },
onSelect = { host.seek(it.start); showChapters = false },
)
}
choosing?.let { type ->
TrackChooser(
type = type,
tracks = tracks.filter { it.type == type },
onDismiss = { choosing = null },
onSelect = { selected ->
host.selectTrack(selected)
choosing = null
scope.launch(Dispatchers.IO) {
val engine = NativeRuntime.engine()
// No per-title choice yet: start from what plays everywhere
// else, so choosing an audio track does not silently drop
// the subtitle preference this episode started with.
val global = engine.playbackPrefs()
val old = engine.titleTrackPreferences(titleKey) ?: TrackPreferencesRecord(
audioLanguage = global.audioLanguage,
subtitleLanguage = global.subtitleLanguage,
subtitles = global.subtitles,
)
engine.setTitleTrackPreferences(
titleKey,
if (type == "audio") {
old.copy(audioLanguage = selected?.language?.takeIf(String::isNotBlank))
} else {
// Turning subtitles off keeps the language it was
// off *from*, so turning them back on does not have
// to be told the language again — as on desktop.
old.copy(
subtitleLanguage = selected?.language?.takeIf(String::isNotBlank)
?: old.subtitleLanguage,
subtitles = selected != null,
)
},
)
}
},
)
}
}
/**
* Persist a volume or a mute, leaving whichever was not given as it was.
*
* Written when the thumb is released rather than while it moves: a drag emits
* dozens of values a second and every one of them would be a file rewrite. A
* failure is dropped — the volume is already applied, and the cost of not
* storing it is that the next launch starts where the last one was stored.
*/
private suspend fun rememberVolume(volume: Double? = null, muted: Boolean? = null) {
runCatching {
val engine = NativeRuntime.engine()
val prefs = engine.playbackPrefs()
engine.setPlaybackPrefs(prefs.copy(volume = volume ?: prefs.volume, muted = muted ?: prefs.muted))
}
}
/** Persist a playback rate, so the next episode starts at it. */
private suspend fun rememberSpeed(speed: Double) {
runCatching {
val engine = NativeRuntime.engine()
engine.setPlaybackPrefs(engine.playbackPrefs().copy(speed = speed))
}
}
/**
* The rates worth offering.
*
* A short list rather than a slider: the useful rates are a handful of ratios,
* and a slider on a phone lands on 1.07 as often as on 1.0.
*/
private val SPEEDS = listOf(0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0)
@Composable
private fun SpeedChooser(current: Double, onDismiss: () -> Unit, onSelect: (Double) -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Playback speed") },
text = {
Column(Modifier.fillMaxWidth()) {
SPEEDS.forEach { rate ->
OutlinedButton(
onClick = { onSelect(rate) },
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
) {
Text(
(if (kotlin.math.abs(rate - current) < 0.001) "✓ " else "") +
if (rate == 1.0) "Normal" else "${rate}×",
)
}
}
}
},
confirmButton = { Button(onClick = onDismiss) { Text("Close") } },
)
}
/** Where each chapter begins, ticked onto the seek bar. */
@Composable
private fun ChapterMarks(chapters: List<ChapterEntry>, duration: Double, modifier: Modifier) {
if (chapters.size < 2 || duration <= 0.0) return
Canvas(modifier.padding(horizontal = 10.dp)) {
val width = 2.dp.toPx()
val height = 8.dp.toPx()
chapters.drop(1).forEach { chapter ->
val fraction = (chapter.start / duration).coerceIn(0.0, 1.0).toFloat()
drawRect(
color = Color.White.copy(alpha = 0.75f),
topLeft = Offset(fraction * size.width - width / 2, (size.height - height) / 2),
size = Size(width, height),
)
}
}
}
@Composable
private fun ChapterChooser(
chapters: List<ChapterEntry>,
position: Double,
onDismiss: () -> Unit,
onSelect: (ChapterEntry) -> Unit,
) {
val current = chapters.indexOfLast { position + 0.001 >= it.start }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Chapters") },
text = {
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
items(chapters, key = ChapterEntry::index) { chapter ->
OutlinedButton(
onClick = { onSelect(chapter) },
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
) {
Text(
(if (chapters.getOrNull(current) === chapter) "✓ " else "") +
"${formatTime(chapter.start)} ${chapter.label}",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
},
confirmButton = { Button(onClick = onDismiss) { Text("Close") } },
)
}
/**
* The countdown into the next episode, shown once the credits run starts.
*
* "Watch till the end" holds for the rest of the file: someone who wants the
* post-credits scene should have to say so once, not once every ten seconds.
*/
@Composable
private fun UpNextCard(
next: EpisodeRecord?,
visible: Boolean,
onPlayNow: () -> Unit,
modifier: Modifier = Modifier,
) {
var held by remember(next?.linkId) { mutableStateOf(false) }
var remaining by remember(next?.linkId) { mutableStateOf(UP_NEXT_SECONDS) }
val upNext = next
val showing = visible && upNext != null && !held
LaunchedEffect(showing) {
if (!showing) return@LaunchedEffect
remaining = UP_NEXT_SECONDS
while (remaining > 0) {
delay(1_000)
remaining -= 1
}
onPlayNow()
}
// `showing` already implies a non-null episode, and the compiler knows it.
if (!showing) return
Column(
modifier
.background(Color.Black.copy(alpha = 0.85f), RoundedCornerShape(12.dp))
.padding(12.dp),
) {
Text("Up next in ${remaining}s", color = Color.White, style = MaterialTheme.typography.labelMedium)
Text(
upNext.label,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleSmall,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 8.dp)) {
Button(onClick = onPlayNow) { Text("Play now") }
TextButton(onClick = { held = true }) { Text("Watch till the end", color = Color.White) }
}
}
}
@Composable
private fun TrackChooser(
type: String,
tracks: List<MpvTrack>,
onDismiss: () -> Unit,
onSelect: (MpvTrack?) -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(if (type == "audio") "Audio track" else "Subtitle track") },
text = {
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
if (type == "sub") {
item {
OutlinedButton(onClick = { onSelect(null) }, Modifier.fillMaxWidth()) {
Text(if (tracks.none(MpvTrack::selected)) "✓ Off" else "Off")
}
}
}
items(tracks, key = MpvTrack::id) { track ->
OutlinedButton(onClick = { onSelect(track) }, Modifier.fillMaxWidth()) {
Text(if (track.selected) "✓ ${track.label}" else track.label)
}
}
}
},
confirmButton = { Button(onClick = onDismiss) { Text("Close") } },
)
}
@Composable
private fun CompactIcon(
icon: androidx.compose.ui.graphics.vector.ImageVector,
description: String,
enabled: Boolean = true,
onClick: () -> Unit,
) {
IconButton(onClick = onClick, enabled = enabled, modifier = Modifier.size(40.dp)) {
Icon(
icon,
contentDescription = description,
tint = if (enabled) Color.White else Color.White.copy(alpha = 0.35f),
modifier = Modifier.size(22.dp),
)
}
}
private fun ((Double) -> Unit).asFloat(): (Float) -> Unit = { this(it.toDouble()) }
/** How much of the episode is left, or nothing before the duration is known. */
private fun remaining(state: MpvPlaybackState): String? = (state.duration - state.position)
.takeIf { state.duration > 0.0 && it >= 1.0 }
?.let { "${formatTime(it)} left" }
private fun formatTime(seconds: Double): String {
val total = seconds.coerceAtLeast(0.0).roundToInt()
val hours = total / 3_600
return if (hours > 0) "%d:%02d:%02d".format(hours, (total % 3_600) / 60, total % 60)
else "%d:%02d".format(total / 60, total % 60)
}
@@ -0,0 +1,102 @@
package io.narl.protonstream.playback
import android.view.Surface
import io.narl.protonstream.native.NativeRuntime
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import uniffi.pstr_android.AndroidEngine
import uniffi.pstr_android.AndroidStream
import uniffi.pstr_android.EpisodeRecord
/** Exact seekable stream contract a bundled libmpv JNI adapter must consume. */
class RustStreamSession(
private val episode: EpisodeRecord,
) {
private var engine: AndroidEngine? = null
private var stream: AndroidStream? = null
private var handle: ULong? = null
private var streamSize: ULong? = null
suspend fun open(): RustStreamSession = withContext(Dispatchers.IO) {
val openedEngine = NativeRuntime.engine()
engine = openedEngine
stream = openedEngine.openStream(episode.shareId, episode.volumeId, episode.linkId).also {
streamSize = it.size()
handle = it.nativeHandle()
}
this@RustStreamSession
}
fun nativeHandle(): ULong = checkNotNull(handle) { "stream is not published" }
fun size(): ULong = checkNotNull(streamSize) { "stream is not open" }
fun releaseNative(host: LibmpvHost) {
handle?.let(host::releaseNativeStream)
handle = null
streamSize = null
stream = null
}
/** The native player now owns the published token until stop/end/close. */
fun transferToPlayer() {
handle = null
streamSize = null
stream = null
}
/**
* Give the episode's stream back to Rust, from a scope of this session's own.
*
* Deliberately not the caller's: the one moment this has to run is when the
* player leaves the composition, which is the same moment a
* `rememberCoroutineScope` is cancelled — so a release launched there is
* cancelled before it starts, and the stream is held for the life of the
* process. Once per episode played, that is every episode.
*/
fun close(host: LibmpvHost) {
releaseNative(host)
sessions.launch {
runCatching { engine?.releaseStream(episode.shareId, episode.volumeId, episode.linkId) }
engine = null
}
}
}
/**
* Where stream teardown runs: process-lifetime, like the engine it releases to,
* and never cancelled by a screen going away.
*/
private val sessions = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/** Implement only in the variant that bundles a pinned libmpv native artifact. */
interface LibmpvHost {
fun attachSurface(surface: Surface)
fun detachSurface()
/**
* Consumes the opaque handle through pstr_android_stream_{read,size}.
*
* [media] names the episode being loaded. One host serves every episode in
* turn, so its readings have to say which one they describe — see
* [MpvPlaybackState.media].
*/
suspend fun play(
media: String,
nativeHandle: ULong,
size: ULong,
startPosition: Double = 0.0,
audioLanguage: String? = null,
subtitleLanguage: String? = null,
subtitles: Boolean = true,
)
/** Calls pstr_android_stream_release exactly once for a published handle. */
fun releaseNativeStream(nativeHandle: ULong)
}
/** How an episode is named in watch state, work names and player readings. */
fun mediaKey(episode: EpisodeRecord) = "${episode.shareId}:${episode.linkId}"
@@ -2,6 +2,13 @@ package io.narl.protonstream.settings
import android.content.Context import android.content.Context
/**
* The settings that are Android's alone.
*
* Playback preferences — volume, languages, autoplay, auto-skip — deliberately
* do *not* live here: they are `pstr_core::prefs`, reached over the bridge, so
* there is one definition of them rather than one per front end.
*/
class SettingsStore(context: Context) { class SettingsStore(context: Context) {
private val preferences = context.getSharedPreferences(NAME, Context.MODE_PRIVATE) private val preferences = context.getSharedPreferences(NAME, Context.MODE_PRIVATE)
@@ -28,8 +28,11 @@ import io.narl.protonstream.native.NativeRuntime
import uniffi.pstr_android.ShareRecord import uniffi.pstr_android.ShareRecord
import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.OfflineRecord import uniffi.pstr_android.OfflineRecord
import uniffi.pstr_android.MatchSummary
import uniffi.pstr_android.MetadataProvider import uniffi.pstr_android.MetadataProvider
import uniffi.pstr_android.MetadataSettingsRecord import uniffi.pstr_android.MetadataSettingsRecord
import uniffi.pstr_android.StorageUsageRecord
import uniffi.pstr_android.EpisodeRecord
data class AppUiState( data class AppUiState(
val loading: Boolean = true, val loading: Boolean = true,
@@ -44,6 +47,7 @@ data class AppUiState(
language = "en", language = "en",
ready = true, ready = true,
), ),
val storage: StorageUsageRecord = StorageUsageRecord(0uL, 0uL, 0uL, 0uL),
val message: String? = null, val message: String? = null,
) )
@@ -55,6 +59,11 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
val state: StateFlow<AppUiState> = mutableState.asStateFlow() val state: StateFlow<AppUiState> = mutableState.asStateFlow()
init { init {
if (!NativeRuntime.tlsReady) {
mutableState.update {
it.copy(message = "Certificate verification is unavailable; Proton requests will fail")
}
}
reload() reload()
viewModelScope.launch { viewModelScope.launch {
searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) } searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) }
@@ -83,6 +92,43 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
} }
} }
/**
* Recrawl one share rather than the whole library.
*
* A viewer who has just added files to one link should not have to pay for
* a walk of every other one — and a share whose link has expired makes a
* whole-library refresh fail, which used to leave no way to refresh the
* shares that still work.
*/
fun refreshShare(id: String) {
viewModelScope.launch {
mutableState.update { it.copy(refreshing = true, message = null) }
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } }
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
mutableState.update { it.copy(refreshing = false) }
reload()
}
}
/**
* Run enrichment again.
*
* With [force] every title is looked up afresh, including ones that already
* matched — the way out of a library where a provider's early answers were
* wrong, or where a match was made against a provider that has since been
* changed.
*/
fun matchTitles(force: Boolean) {
viewModelScope.launch {
mutableState.update { it.copy(refreshing = true, message = null) }
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().matchTitles(force) } }
.onSuccess { summary -> mutableState.update { it.copy(message = describe(summary)) } }
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
mutableState.update { it.copy(refreshing = false) }
reload()
}
}
fun addShare(name: String, url: String, password: String?) { fun addShare(name: String, url: String, password: String?) {
viewModelScope.launch { viewModelScope.launch {
runCatching { runCatching {
@@ -96,6 +142,28 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
} }
} }
/**
* Re-supply the link behind a share whose stored secret cannot be read.
*
* The Keystore key is invalidated by a lockscreen change or a device
* restore, and after that every open of every share fails — including the
* read inside "remove this share", so the obvious way out is closed too.
* Re-entering the link rewrites the secret and leaves the catalog and the
* offline files alone.
*/
fun repairShare(id: String, url: String, password: String?) {
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
engine.repairShare(id, url, password?.takeIf(String::isNotBlank))
engine.crawl(id)
}
}.onFailure(::reportError)
reload()
}
}
fun removeShare(id: String) { fun removeShare(id: String) {
viewModelScope.launch { viewModelScope.launch {
runCatching { runCatching {
@@ -132,8 +200,10 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
engine.setMetadataApiKey(provider, apiKey) engine.setMetadataApiKey(provider, apiKey)
} }
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true)) engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
if (enabled) engine.matchTitles(false) if (enabled) engine.matchTitles(false) else null
} }
}.onSuccess { summary ->
summary?.let { mutableState.update { state -> state.copy(message = describe(it)) } }
}.onFailure(::reportError) }.onFailure(::reportError)
reload() reload()
} }
@@ -141,6 +211,23 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
fun reloadAfterMetadataChange() = reload() fun reloadAfterMetadataChange() = reload()
/**
* What an enrichment pass did, in one line.
*
* Failures are reported even when most titles matched: a run that quietly
* dropped a third of the library is the case this counting exists for.
*/
private fun describe(summary: MatchSummary): String {
if (summary.matched == 0u && summary.unmatched == 0u && summary.failed == 0u) {
return "Everything is already matched"
}
val parts = mutableListOf("${summary.matched} matched")
if (summary.unmatched > 0u) parts += "${summary.unmatched} not found"
if (summary.episodes > 0u) parts += "${summary.episodes} episodes named"
if (summary.failed > 0u) parts += "${summary.failed} failed"
return parts.joinToString(", ")
}
fun removeOffline(file: OfflineRecord) { fun removeOffline(file: OfflineRecord) {
viewModelScope.launch { viewModelScope.launch {
runCatching { runCatching {
@@ -170,16 +257,96 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
} }
} }
/**
* Persist where an episode was left.
*
* The player calls this while it is still up and once more as it leaves, so
* it runs on the ViewModel's scope rather than the screen's — a save issued
* on the way out must not be cancelled by the screen going away.
*/
fun saveProgress(episode: EpisodeRecord, position: Double, duration: Double, watched: Boolean) {
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
NativeRuntime.engine().saveWatchState(
episode.shareId,
episode.linkId,
position.coerceIn(0.0, duration),
duration,
watched,
)
}
}.onFailure(::reportError)
}
}
/**
* Mark an episode seen, or unseen, without playing it.
*
* Unwatching rewinds, matching the desktop client: an episode marked unseen
* that kept its position would come straight back as "resume at 19:04",
* which is not what the viewer asked for. Marking one seen puts the position
* at its duration where one is known, so the progress bar agrees with the
* tick.
*/
fun setWatched(episode: EpisodeRecord, watched: Boolean) {
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
val duration = engine.watchState(episode.shareId, episode.linkId)?.durationSecs
engine.saveWatchState(
episode.shareId,
episode.linkId,
if (watched) duration ?: 0.0 else 0.0,
duration,
watched,
)
}
}.onFailure(::reportError)
reload()
}
}
fun removeAllOffline() {
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
workManager.cancelAllWorkByTag(DownloadCoordinator.TAG).result.get()
NativeRuntime.engine().removeAllOffline()
DownloadStateStore(appContext).clear()
}
}.onFailure(::reportError)
reload()
}
}
fun clearBlockCache() {
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) { NativeRuntime.engine().clearBlockCache() }
}.onSuccess { reclaimed ->
mutableState.update { it.copy(message = "Reclaimed ${formatBytes(reclaimed)} of cache") }
}.onFailure(::reportError)
reload()
}
}
private fun reload() { private fun reload() {
viewModelScope.launch { viewModelScope.launch {
runCatching { runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine() val engine = NativeRuntime.engine()
// Here rather than inside `library()`, which is read-only
// and cached: a full reload is the one place that has
// already paid for a walk of the offline files.
engine.pruneOfflineFiles()
Reloaded( Reloaded(
engine.shares(), engine.shares(),
engine.library(mutableState.value.query.takeIf(String::isNotBlank)), engine.library(mutableState.value.query.takeIf(String::isNotBlank)),
engine.offlineFiles(), engine.offlineFiles(),
engine.metadataSettings(), engine.metadataSettings(),
engine.storageUsage(),
) )
} }
}.onSuccess { result -> }.onSuccess { result ->
@@ -189,6 +356,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
titles = result.titles, titles = result.titles,
offline = result.offline, offline = result.offline,
metadataSettings = result.metadataSettings, metadataSettings = result.metadataSettings,
storage = result.storage,
) } ) }
} }
.onFailure { error -> .onFailure { error ->
@@ -215,6 +383,7 @@ private data class Reloaded(
val titles: List<TitleRecord>, val titles: List<TitleRecord>,
val offline: List<OfflineRecord>, val offline: List<OfflineRecord>,
val metadataSettings: MetadataSettingsRecord, val metadataSettings: MetadataSettingsRecord,
val storage: StorageUsageRecord,
) )
private fun WorkManager.workInfosByTagFlow(tag: String) = callbackFlow { private fun WorkManager.workInfosByTagFlow(tag: String) = callbackFlow {
File diff suppressed because it is too large. Load diff
@@ -1,6 +1,9 @@
package io.narl.protonstream.ui package io.narl.protonstream.ui
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.util.LruCache
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -19,50 +22,61 @@ import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import io.narl.protonstream.native.NativeRuntime
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.TitleRecord
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URI import java.net.URI
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File
import java.io.InputStream import java.io.InputStream
import java.security.MessageDigest import java.security.MessageDigest
import java.util.Collections
/** HTTPS-only artwork loader backed by the app-private cache directory. */ /**
* One file to pull Proton's own thumbnail from when no provider poster exists.
*
* Proton renders a thumbnail per file, so any episode of a show is a frame of
* the right show — the desktop client picks the first for the same reason.
*/
data class ThumbnailSource(val shareId: String, val volumeId: String, val linkId: String) {
val key: String get() = "$shareId/$linkId"
}
/**
* What the first episode of a title offers as a poster.
*
* Metadata lookups are off by default, so for a freshly crawled library this is
* the only artwork there is.
*/
internal val TitleRecord.thumbnailSource: ThumbnailSource?
get() = seasons.firstNotNullOfOrNull { season -> season.episodes.firstOrNull() }
?.let { ThumbnailSource(it.shareId, it.volumeId, it.linkId) }
/** What one episode offers as a still. */
internal val EpisodeRecord.thumbnailSource: ThumbnailSource
get() = ThumbnailSource(shareId, volumeId, linkId)
/**
* HTTPS-only artwork loader backed by the app-private cache directory.
*
* [fallback] is used only when there is no provider artwork or it fails to
* load, which — with metadata off — is the common case rather than the rare
* one.
*/
@Composable @Composable
internal fun RemoteArtwork(url: String?, description: String, modifier: Modifier = Modifier) { internal fun RemoteArtwork(
url: String?,
description: String,
modifier: Modifier = Modifier,
fallback: ThumbnailSource? = null,
) {
val context = LocalContext.current val context = LocalContext.current
var image by remember(url) { mutableStateOf<ImageBitmap?>(null) } var image by remember(url, fallback) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(url) { LaunchedEffect(url, fallback) {
image = url?.let { requested -> image = (loadArtwork(context, url) ?: loadThumbnail(fallback))?.asImageBitmap()
withContext(Dispatchers.IO) {
runCatching {
val uri = URI(requested)
require(uri.scheme.equals("https", ignoreCase = true)) { "Artwork must use HTTPS" }
val directory = context.cacheDir.resolve("metadata-art").apply { mkdirs() }
val digest = MessageDigest.getInstance("SHA-256")
.digest(requested.toByteArray()).joinToString("") { "%02x".format(it) }
val cached = directory.resolve("$digest.img")
val bytes = if (cached.isFile) cached.readBytes() else {
val connection = uri.toURL().openConnection() as HttpURLConnection
connection.connectTimeout = 10_000
connection.readTimeout = 10_000
connection.instanceFollowRedirects = true
val status = connection.responseCode
require(status in 200..299) { "Artwork request failed ($status)" }
require(connection.url.protocol.equals("https", ignoreCase = true)) {
"Artwork redirect must use HTTPS"
}
connection.inputStream.use(::readBounded).also { downloaded ->
require(downloaded.size <= MAX_ART_BYTES) { "Artwork is too large" }
val temporary = directory.resolve("$digest.part")
temporary.writeBytes(downloaded)
check(temporary.renameTo(cached)) { "Unable to cache artwork" }
}
}
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
}.getOrNull()
}
}
} }
Box(modifier.background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center) { Box(modifier.background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center) {
image?.let { image?.let {
@@ -71,7 +85,153 @@ internal fun RemoteArtwork(url: String?, description: String, modifier: Modifier
} }
} }
/**
* The same HTTPS-only fetch behind the library grid, reusable off the composition.
*
* The media notification needs a bitmap rather than an [ImageBitmap], and needs
* it from a service with no composition of its own — but it wants exactly this
* loader's protocol checks and exactly this disk cache, so the artwork the shade
* shows costs no second download.
*/
internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?.let { requested ->
decoded.get(requested) ?: withContext(Dispatchers.IO) {
runCatching {
val uri = URI(requested)
require(uri.scheme.equals("https", ignoreCase = true)) { "Artwork must use HTTPS" }
val directory = context.cacheDir.resolve("metadata-art").apply { mkdirs() }
val digest = MessageDigest.getInstance("SHA-256")
.digest(requested.toByteArray()).joinToString("") { "%02x".format(it) }
val cached = directory.resolve("$digest.img")
val fresh = !cached.isFile
val bytes = if (fresh) download(uri) else cached.readBytes()
val bitmap = requireNotNull(decodeSampled(bytes)) { "Artwork is not a decodable image" }
if (fresh) {
// Written only once it has decoded. Caching the bytes first is
// what turns one hostile poster into a crash on every launch
// that survives until app data is cleared.
val temporary = directory.resolve("$digest.part")
temporary.writeBytes(bytes)
check(temporary.renameTo(cached)) { "Unable to cache artwork" }
prune(directory)
} else {
cached.setLastModified(System.currentTimeMillis())
}
bitmap
}.getOrNull()?.also { decoded.put(requested, it) }
}
}
/**
* Proton's own thumbnail for one file, decoded.
*
* The bridge holds the disk cache, so what is worth keeping here is the decoded
* bitmap and the *absence* of one: a file with no thumbnail is common, and
* without remembering that, every scroll back over the grid pays a round trip
* per tile to be told so again. A failed call is not remembered — an unreachable
* share or a session that has to be reopened is transient, and the next
* composition should try again.
*/
internal suspend fun loadThumbnail(source: ThumbnailSource?): Bitmap? {
val file = source ?: return null
val key = file.key
decoded.get(key)?.let { return it }
if (key in missing) return null
val fetched = withContext(Dispatchers.IO) {
runCatching { NativeRuntime.engine().thumbnail(file.shareId, file.volumeId, file.linkId) }
}
val bytes = fetched.getOrElse { return null }
val bitmap = bytes?.let { withContext(Dispatchers.Default) { decodeSampled(it) } }
if (bitmap == null) {
missing.add(key)
return null
}
decoded.put(key, bitmap)
return bitmap
}
private fun download(uri: URI): ByteArray {
val connection = uri.toURL().openConnection() as HttpURLConnection
connection.connectTimeout = 10_000
connection.readTimeout = 10_000
connection.instanceFollowRedirects = true
val status = connection.responseCode
require(status in 200..299) { "Artwork request failed ($status)" }
require(connection.url.protocol.equals("https", ignoreCase = true)) {
"Artwork redirect must use HTTPS"
}
return connection.inputStream.use(::readBounded)
.also { require(it.size <= MAX_ART_BYTES) { "Artwork is too large" } }
}
/**
* Decode within a bound the display can actually use.
*
* [MAX_ART_BYTES] bounds the *compressed* image, which says nothing about what
* it costs decoded: a 12 MiB PNG can be 12000×12000, and that is a 576 MB
* `ARGB_8888` allocation on a device that will not give it. The bounds pass
* costs no allocation and settles a sample size before anything is committed.
*/
private fun decodeSampled(bytes: ByteArray): Bitmap? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
val longest = maxOf(bounds.outWidth, bounds.outHeight)
if (longest <= 0) return null
var sample = 1
while (longest / sample > MAX_ART_EDGE) sample *= 2
return BitmapFactory.decodeByteArray(
bytes,
0,
bytes.size,
BitmapFactory.Options().apply { inSampleSize = sample },
)
}
/**
* Hold the cache directory under [MAX_ART_CACHE_BYTES], oldest first.
*
* `cacheDir` is reclaimable by the system, but only under storage pressure and
* only wholesale — a poster cache that grows with every title ever browsed is
* still the app's own doing.
*/
private fun prune(directory: File) {
val files = directory.listFiles()?.sortedBy(File::lastModified) ?: return
var total = files.sumOf(File::length)
for (file in files) {
if (total <= MAX_ART_CACHE_BYTES) return
total -= file.length()
file.delete()
}
}
/**
* Files known to have no Proton thumbnail, so the answer is paid for once.
*
* Deliberately not persisted: a re-upload can add one, and the cost of being
* wrong for a session is a placeholder that a restart clears.
*/
private val missing: MutableSet<String> = Collections.synchronizedSet(mutableSetOf())
/**
* Decoded posters, so scrolling the grid re-reads neither disk nor decoder.
*
* Keyed by artwork URL or by `share/link` for a Proton thumbnail; the two
* cannot collide, since one is always an `https:` URL.
*
* `remember(url)` is per-composition, and a `LazyVerticalGrid` drops and
* recreates items as they leave the viewport — without this, every scroll back
* is a full-resolution decode per item.
*/
private val decoded = object : LruCache<String, Bitmap>(
(Runtime.getRuntime().maxMemory() / 8).coerceIn(4L * 1024 * 1024, 32L * 1024 * 1024).toInt(),
) {
override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount
}
private const val MAX_ART_BYTES = 12 * 1024 * 1024 private const val MAX_ART_BYTES = 12 * 1024 * 1024
private const val MAX_ART_CACHE_BYTES = 48L * 1024 * 1024
/** Longest edge worth decoding: a poster on a phone, or a notification icon. */
private const val MAX_ART_EDGE = 1_024
/** Reads at most [MAX_ART_BYTES] plus one byte without requiring API 33's readNBytes. */ /** Reads at most [MAX_ART_BYTES] plus one byte without requiring API 33's readNBytes. */
private fun readBounded(input: InputStream): ByteArray { private fun readBounded(input: InputStream): ByteArray {
@@ -1,24 +1,103 @@
package io.narl.protonstream.ui.theme package io.narl.protonstream.ui.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import io.narl.protonstream.native.NativeRuntime
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import uniffi.pstr_android.PaletteRecord
private val ProtonColors = darkColorScheme( /**
primary = Color(0xFFCBA6F7), * The palette in force, shared across every composition.
onPrimary = Color(0xFF1E1E2E), *
secondary = Color(0xFFF5C2E7), * The choice is stored by Rust and resolved by Rust — the same flavours, the
background = Color(0xFF1E1E2E), * same accent pairings and the same contrast rule the desktop client uses — so
surface = Color(0xFF181825), * what lives here is only the resolved answer and a way to replace it when the
surfaceVariant = Color(0xFF313244), * viewer picks something else.
onBackground = Color(0xFFCDD6F4), */
onSurface = Color(0xFFCDD6F4), object AppearanceState {
outline = Color(0xFF6C7086), private val current = MutableStateFlow<PaletteRecord?>(null)
error = Color(0xFFF38BA8), val palette: StateFlow<PaletteRecord?> = current.asStateFlow()
)
/** Read the stored choice. Safe to call repeatedly; the last read wins. */
suspend fun load() {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().palette() } }
.onSuccess { current.value = it }
}
/** Repaint immediately, before the write that stores the choice lands. */
fun apply(palette: PaletteRecord) {
current.value = palette
}
}
@Composable @Composable
fun ProtonStreamTheme(content: @Composable () -> Unit) { fun ProtonStreamTheme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = ProtonColors, content = content) val palette by AppearanceState.palette.collectAsState()
// Before the first read, and if it fails, the palette this app shipped
// with — a theme file that cannot be read must never be a reason the
// library does not open.
LaunchedEffect(Unit) { AppearanceState.load() }
MaterialTheme(colorScheme = palette?.let(::schemeOf) ?: ProtonColors, content = content)
} }
/**
* One resolved palette as Material's roles.
*
* Material names more roles than a palette has colours, so several are answered
* by the same one — `surfaceVariant` and `card` above all, which is what makes
* a tile and a form field the same shade in both clients.
*/
private fun schemeOf(palette: PaletteRecord): ColorScheme {
val base = if (palette.light) lightColorScheme() else darkColorScheme()
return base.copy(
primary = color(palette.accent),
onPrimary = color(palette.onAccent),
primaryContainer = color(palette.accentDim),
onPrimaryContainer = color(palette.text),
secondary = color(palette.accentAlt),
onSecondary = color(palette.onAccent),
secondaryContainer = color(palette.cardHover),
onSecondaryContainer = color(palette.text),
background = color(palette.background),
onBackground = color(palette.text),
surface = color(palette.surface),
onSurface = color(palette.text),
surfaceVariant = color(palette.card),
onSurfaceVariant = color(palette.muted),
surfaceContainer = color(palette.card),
surfaceContainerHigh = color(palette.cardHover),
surfaceContainerLow = color(palette.sunken),
outline = color(palette.border),
outlineVariant = color(palette.muted),
error = color(palette.danger),
onError = color(palette.background),
)
}
/** A packed `0xAARRGGBB` from the bridge, as Compose wants it. */
private fun color(argb: UInt) = Color(argb.toInt())
/** The shipped default, spelled out for the frames before the store is read. */
private val ProtonColors = darkColorScheme(
primary = Color(0xFF7D4DFF),
onPrimary = Color(0xFFEAEAF0),
secondary = Color(0xFF3F6FE0),
background = Color(0xFF0E0E12),
surface = Color(0xFF17171D),
surfaceVariant = Color(0xFF1E1E26),
onBackground = Color(0xFFEAEAF0),
onSurface = Color(0xFFEAEAF0),
outline = Color(0xFF2A2A35),
error = Color(0xFFE05561),
)
@@ -0,0 +1,13 @@
<!--
The status-bar glyph. Android tints small icons to a single colour and drops
everything that is not opaque, so this is the launcher mark reduced to one
white silhouette rather than the two-tone drawable.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:fillType="evenOdd"
android:pathData="M4,4h16v16h-16zM10,8.5l5.5,3.5 -5.5,3.5z" />
</vector>
@@ -0,0 +1,155 @@
package io.narl.protonstream.download
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class DownloadStateStoreTest {
private lateinit var context: Context
private lateinit var store: DownloadStateStore
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit().clear().commit()
store = DownloadStateStore(context)
}
private fun record(
shareId: String = "share-1",
linkId: String = "link-1",
label: String = "S01E01.mkv",
status: String = RetainedDownload.STATUS_RUNNING,
error: String? = null,
) = RetainedDownload(
shareId = shareId,
volumeId = "volume-1",
linkId = linkId,
label = label,
downloaded = 4L * 1024 * 1024,
total = 761L * 1024 * 1024,
status = status,
error = error,
)
@Test
fun `a record survives a round trip unchanged`() {
val original = record()
store.put(original)
assertEquals(original, store.get("share-1", "link-1"))
}
@Test
fun `an absent record reads as null rather than a default`() {
assertNull(store.get("share-1", "link-1"))
}
@Test
fun `an error message round trips and a null one stays null`() {
store.put(record(error = "no space left on device"))
assertEquals("no space left on device", store.get("share-1", "link-1")?.error)
store.put(record(error = null))
assertNull(store.get("share-1", "link-1")?.error)
}
/**
* `encode` writes `error` through `JSONObject.put`, which *removes* the key
* when the value is null. `decode` then reads `optString`, which answers ""
* rather than null. The blank check is the only thing standing between a
* cleared error and a download that renders as permanently failed.
*/
@Test
fun `a blank error is not read as a failure message`() {
writeRaw(
"share-1",
"link-1",
baseJson().put("error", ""),
)
assertNull(store.get("share-1", "link-1")?.error)
}
/** Rows written before `label` existed fall back to the link id. */
@Test
fun `a record with no label is captioned with its link id`() {
writeRaw("share-1", "link-1", baseJson().apply { remove("label") })
assertEquals("link-1", store.get("share-1", "link-1")?.label)
}
@Test
fun `a corrupt row is skipped rather than failing the whole list`() {
store.put(record(linkId = "link-1", label = "good.mkv"))
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.edit()
.putString("share-1\u001flink-broken", "{ not json")
.commit()
val records = store.records()
assertEquals(1, records.size)
assertEquals("good.mkv", records.first().label)
}
@Test
fun `records are ordered by label without regard to case`() {
store.put(record(linkId = "a", label = "beta.mkv"))
store.put(record(linkId = "b", label = "Alpha.mkv"))
assertEquals(listOf("Alpha.mkv", "beta.mkv"), store.records().map { it.label })
}
@Test
fun `removing one share leaves the others alone`() {
store.put(record(shareId = "share-1", linkId = "a"))
store.put(record(shareId = "share-2", linkId = "b"))
store.removeShare("share-1")
assertEquals(listOf("share-2"), store.records().map { it.shareId })
}
@Test
fun `clear forgets everything`() {
store.put(record(linkId = "a"))
store.put(record(linkId = "b"))
store.clear()
assertTrue(store.records().isEmpty())
}
/**
* The key is what `DownloadCoordinator` and `OfflineDownloadWorker` agree on
* across processes; two shares may legitimately hold the same link id.
*/
@Test
fun `the key separates share from link so ids cannot run together`() {
store.put(record(shareId = "a", linkId = "b-c"))
store.put(record(shareId = "a-b", linkId = "c"))
assertEquals(2, store.records().size)
}
private fun baseJson() = JSONObject()
.put("share", "share-1")
.put("volume", "volume-1")
.put("link", "link-1")
.put("label", "S01E01.mkv")
.put("downloaded", 0)
.put("total", 0)
.put("status", RetainedDownload.STATUS_QUEUED)
private fun writeRaw(shareId: String, linkId: String, json: JSONObject) {
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.edit()
.putString("$shareId\u001f$linkId", json.toString())
.commit()
}
private companion object {
const val PREFERENCES = "offline_download_state"
}
}
@@ -0,0 +1,11 @@
# Robolectric ships no Android image for API 36 yet, and defaults to the app's
# targetSdk, so host tests would fail to start. 34 is the newest it has and is
# well above minSdk 31; anything whose behaviour actually turns on the API level
# belongs in androidTest, on a real device, not here.
sdk=34
# ProtonStreamApplication.onCreate() calls System.loadLibrary("pstr_android"),
# which has no host build. Host tests get a plain Application; everything that
# needs the bridge is an instrumentation test by construction — see
# androidTest/.../UniFfiBoundaryTest.
application=android.app.Application
+13 -3
View File
@@ -6,10 +6,14 @@ activity = "1.11.0"
lifecycle = "2.9.4" lifecycle = "2.9.4"
navigation = "2.9.5" navigation = "2.9.5"
work = "2.10.5" work = "2.10.5"
media3 = "1.8.0"
adaptive = "1.1.0" adaptive = "1.1.0"
jna = "5.17.0" jna = "5.17.0"
junit = "4.13.2" junit = "4.13.2"
robolectric = "4.16"
androidx-test-core = "1.7.0"
androidx-test-junit = "1.3.0"
androidx-test-runner = "1.7.0"
ktlint-plugin = "13.1.0"
[libraries] [libraries]
androidx-core = { module = "androidx.core:core-ktx", version = "1.17.0" } androidx-core = { module = "androidx.core:core-ktx", version = "1.17.0" }
@@ -18,8 +22,7 @@ androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-kt
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" }
androidx-work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "work" } androidx-work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "media3" } androidx-work-testing = { module = "androidx.work:work-testing", version.ref = "work" }
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" } androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
androidx-compose-ui = { module = "androidx.compose.ui:ui" } androidx-compose-ui = { module = "androidx.compose.ui:ui" }
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
@@ -29,11 +32,18 @@ androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" } androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" }
androidx-compose-material3-navigation-suite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite", version.ref = "adaptive" } androidx-compose-material3-navigation-suite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite", version.ref = "adaptive" }
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
rustls-platform-verifier = { module = "rustls:rustls-platform-verifier", version = "0.1.1" } rustls-platform-verifier = { module = "rustls:rustls-platform-verifier", version = "0.1.1" }
junit = { module = "junit:junit", version.ref = "junit" } junit = { module = "junit:junit", version.ref = "junit" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
androidx-test-core = { module = "androidx.test:core-ktx", version.ref = "androidx-test-core" }
androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" }
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-plugin" }
+1 -1
View File
@@ -29,6 +29,7 @@ thiserror.workspace = true
uniffi.workspace = true uniffi.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
log = "0.4"
[target.'cfg(target_os = "android")'.dependencies] [target.'cfg(target_os = "android")'.dependencies]
# Match rustls-platform-verifier's JNI types: Java objects from different # Match rustls-platform-verifier's JNI types: Java objects from different
@@ -36,4 +37,3 @@ serde_json.workspace = true
jni = "0.21.1" jni = "0.21.1"
rustls-platform-verifier = "0.6.2" rustls-platform-verifier = "0.6.2"
android_logger = "0.14" android_logger = "0.14"
log = "0.4"
File diff suppressed because it is too large. Load diff
+3 -50
View File
@@ -1622,7 +1622,7 @@ impl Engine {
// Only for a title that matched: there is no id to // Only for a title that matched: there is no id to
// ask about otherwise. // ask about otherwise.
let episodes = match &record.metadata { let episodes = match &record.metadata {
Some(found) => episodes_of(&service, title, found).await, Some(found) => service.title_episodes(title, found).await,
None => Vec::new(), None => Vec::new(),
}; };
Ok((record.title_key.clone(), Some(record), episodes)) Ok((record.title_key.clone(), Some(record), episodes))
@@ -1632,7 +1632,7 @@ impl Engine {
Work::Episodes(title, found) => Ok(( Work::Episodes(title, found) => Ok((
title.key.clone(), title.key.clone(),
None, None,
episodes_of(&service, title, found).await, service.title_episodes(title, found).await,
)), )),
}) })
}); });
@@ -1744,7 +1744,7 @@ impl Engine {
return engine.fail("store the match", error); return engine.fail("store the match", error);
} }
let episodes = episodes_of(&service, &title, &found).await; let episodes = service.title_episodes(&title, &found).await;
if !episodes.is_empty() { if !episodes.is_empty() {
let stored = engine.catalog.lock().set_episode_metadata( let stored = engine.catalog.lock().set_episode_metadata(
&title.key, &title.key,
@@ -1855,53 +1855,6 @@ enum Work {
Episodes(Title, Box<pstr_core::metadata::TitleMetadata>), Episodes(Title, Box<pstr_core::metadata::TitleMetadata>),
} }
/// The episode list for a whole title, with a failure treated as an empty one.
///
/// Deliberately not fatal to the title it belongs to: a poster and a synopsis
/// that arrived are worth keeping even when the episode request was the one
/// that hit the rate limit. The empty result is not cached as an answer — the
/// next match run asks again — because the only thing that triggers one is a
/// viewer pressing the button.
///
/// The match itself only ever answers for *one* entry, and on a provider that
/// files each sequel separately — AniList — that entry is season one. So each
/// further season of the title is searched for by name and its episodes are
/// tagged with the season they came from; without that, seasons two and three
/// have no episode names at all. See
/// [`MetadataService::season_episodes`](pstr_meta::MetadataService::season_episodes).
async fn episodes_of(
service: &MetadataService,
title: &Title,
found: &pstr_core::metadata::TitleMetadata,
) -> Vec<pstr_core::metadata::EpisodeMetadata> {
let mut episodes = service
.episodes(found)
.await
.inspect_err(|error| tracing::warn!("episodes for {}: {error}", found.name))
.unwrap_or_default();
if !service.splits_seasons() {
return episodes;
}
// Season one is what the title's own match already answered for.
let later: Vec<u32> = title
.seasons
.iter()
.filter_map(|season| season.number)
.filter(|number| *number > 1)
.collect();
for season in later {
match service.season_episodes(title, season).await {
Ok(found) => episodes.extend(found),
Err(error) => {
tracing::warn!("episodes for {} season {season}: {error}", title.name);
}
}
}
episodes
}
/// Unix seconds, for the "when was this asked" columns. /// Unix seconds, for the "when was this asked" columns.
fn now() -> i64 { fn now() -> i64 {
std::time::SystemTime::now() std::time::SystemTime::now()
+53 -354
View File
@@ -23,11 +23,11 @@ use std::collections::BTreeMap;
use std::sync::Arc; use std::sync::Arc;
use egui::{ use egui::{
Color32, CornerRadius, Rect, Rgba, Stroke, SystemTheme, TextureHandle, TextureOptions, Theme, Color32, CornerRadius, Rect, Stroke, SystemTheme, TextureHandle, TextureOptions, Theme,
ThemePreference, ViewportCommand, Visuals, ThemePreference, ViewportCommand, Visuals,
}; };
use parking_lot::RwLock; use parking_lot::RwLock;
use pstr_core::appearance::{Accent, Appearance, Flavor}; use pstr_core::appearance::Appearance;
/// Card geometry. The grid is built from these, so a change here moves /// Card geometry. The grid is built from these, so a change here moves
/// everything together. /// everything together.
@@ -91,11 +91,12 @@ fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
None None
} }
/// Every colour the app draws with, resolved from a [`Flavor`] and an /// Every colour the app draws with, as egui wants them.
/// [`Accent`].
/// ///
/// `Copy`, and small, so reading it is a lock and a memcpy rather than anything /// The *choice* of colours is `pstr_core::appearance` — one resolution shared
/// a drawing loop has to think about. /// with the Android client, so a flavour means the same thing on both. This is
/// only that palette converted into `Color32`, which is a type `pstr-core`
/// deliberately knows nothing about.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Palette { pub struct Palette {
/// Behind everything: the page. /// Behind everything: the page.
@@ -118,15 +119,54 @@ pub struct Palette {
/// The accent taken down into the background, for a pressed control and for /// The accent taken down into the background, for a pressed control and for
/// selection behind text. /// selection behind text.
pub accent_dim: Color32, pub accent_dim: Color32,
/// Ink that stays readable on top of the accent. Chosen by the accent's /// Ink that stays readable on top of the accent.
/// luminance, because Catppuccin's dark flavours have *pale* accents and
/// white-on-pastel is not readable at 13 px.
pub on_accent: Color32, pub on_accent: Color32,
pub danger: Color32, pub danger: Color32,
/// Whether this is a light theme. /// Whether this is a light theme.
pub light: bool, pub light: bool,
} }
const fn color(rgb: pstr_core::appearance::Rgb) -> Color32 {
Color32::from_rgb(rgb.r, rgb.g, rgb.b)
}
impl Palette {
/// The default, spelled out so the global has something to hold before
/// [`apply`] first runs — a `Ui` built during startup, or a panic message
/// painted before the engine exists, still gets colours.
const PROTON: Self = Self::of(pstr_core::appearance::Palette::PROTON);
const fn of(palette: pstr_core::appearance::Palette) -> Self {
Self {
background: color(palette.background),
surface: color(palette.surface),
sunken: color(palette.sunken),
card: color(palette.card),
card_hover: color(palette.card_hover),
border: color(palette.border),
text: color(palette.text),
muted: color(palette.muted),
accent: color(palette.accent),
accent_alt: color(palette.accent_alt),
accent_dim: color(palette.accent_dim),
on_accent: color(palette.on_accent),
danger: color(palette.danger),
light: palette.light,
}
}
/// Resolve a choice into colours.
pub fn resolve(appearance: Appearance) -> Self {
Self::of(pstr_core::appearance::Palette::resolve(appearance))
}
}
/// Blend two colours in linear light, through the shared implementation.
fn mix(from: Color32, to: Color32, t: f32) -> Color32 {
let rgb = |c: Color32| pstr_core::appearance::Rgb::new(c.r(), c.g(), c.b());
color(pstr_core::appearance::mix(rgb(from), rgb(to), t))
}
/// Which way a gradient runs. /// Which way a gradient runs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction { pub enum Direction {
@@ -437,353 +477,10 @@ fn ramp(ctx: &egui::Context, from: Color32, to: Color32, direction: Direction) -
/// light, which is what this does. /// light, which is what this does.
const RAMP_STOPS: usize = 64; const RAMP_STOPS: usize = 64;
/// Blend two colours in linear light. Mixing in sRGB darkens the middle of a
/// ramp between saturated hues, which is exactly where a gradient is looked at.
fn mix(from: Color32, to: Color32, t: f32) -> Color32 {
let from = Rgba::from(from);
let to = Rgba::from(to);
Color32::from(from * (1.0 - t) + to * t)
}
/// Relative luminance, in linear light, as WCAG defines it.
fn luminance(color: Color32) -> f32 {
let rgba = Rgba::from(color);
0.2126 * rgba.r() + 0.7152 * rgba.g() + 0.0722 * rgba.b()
}
/// WCAG contrast between two opaque colours: 1.0 for a colour against itself,
/// 21.0 for black against white.
fn contrast(one: Color32, other: Color32) -> f32 {
let (one, other) = (luminance(one), luminance(other));
(one.max(other) + 0.05) / (one.min(other) + 0.05)
}
/// The least contrast a label gets anywhere along a gradient between two
/// colours. Sampled rather than solved: the worst point is not always an end,
/// because the ink can be brighter than one end and darker than the other.
fn worst_contrast(from: Color32, to: Color32, ink: Color32) -> f32 {
(0..=4)
.map(|step| contrast(mix(from, to, step as f32 / 4.0), ink))
.fold(f32::INFINITY, f32::min)
}
/// The least contrast the app will accept between a label and the fill under
/// it: WCAG AA for large text, which is what wears the accent — button labels,
/// a tab, a play glyph, never body copy.
const MIN_CONTRAST: f32 = 3.0;
/// One flavour's raw colours, named as Catppuccin names them.
///
/// Stored rather than computed: these are somebody else's palette, and the
/// point of using it is to use it exactly.
#[derive(Clone, Copy)]
struct Ramp {
base: Color32,
mantle: Color32,
crust: Color32,
surface0: Color32,
surface1: Color32,
subtext0: Color32,
text: Color32,
pink: Color32,
mauve: Color32,
sky: Color32,
sapphire: Color32,
blue: Color32,
lavender: Color32,
teal: Color32,
green: Color32,
peach: Color32,
yellow: Color32,
red: Color32,
light: bool,
}
const fn rgb(hex: u32) -> Color32 {
Color32::from_rgb(
((hex >> 16) & 0xff) as u8,
((hex >> 8) & 0xff) as u8,
(hex & 0xff) as u8,
)
}
/// The palette this app shipped with, extended to the hues an accent can be.
///
/// Only `mauve` — Proton purple — and `red` are from the original; the rest are
/// chosen to sit on a near-black base at roughly the saturation that one does,
/// which is a good deal hotter than any Catppuccin flavour.
const PROTON: Ramp = Ramp {
base: rgb(0x0e0e12),
mantle: rgb(0x17171d),
crust: rgb(0x0a0a0d),
surface0: rgb(0x1e1e26),
surface1: rgb(0x2a2a35),
subtext0: rgb(0x8e8e9c),
text: rgb(0xeaeaf0),
pink: rgb(0xff5fbe),
mauve: rgb(0x7d4dff),
sky: rgb(0x4dd2ff),
sapphire: rgb(0x3a8fc4),
blue: rgb(0x3f6fe0),
lavender: rgb(0xa68cff),
teal: rgb(0x3fd6b8),
green: rgb(0x56d364),
peach: rgb(0xff9a4d),
yellow: rgb(0xffd166),
red: rgb(0xe05561),
light: false,
};
const LATTE: Ramp = Ramp {
base: rgb(0xeff1f5),
mantle: rgb(0xe6e9ef),
crust: rgb(0xdce0e8),
surface0: rgb(0xccd0da),
surface1: rgb(0xbcc0cc),
subtext0: rgb(0x6c6f85),
text: rgb(0x4c4f69),
pink: rgb(0xea76cb),
mauve: rgb(0x8839ef),
sky: rgb(0x04a5e5),
sapphire: rgb(0x209fb5),
blue: rgb(0x1e66f5),
lavender: rgb(0x7287fd),
teal: rgb(0x179299),
green: rgb(0x40a02b),
peach: rgb(0xfe640b),
yellow: rgb(0xdf8e1d),
red: rgb(0xd20f39),
light: true,
};
const FRAPPE: Ramp = Ramp {
base: rgb(0x303446),
mantle: rgb(0x292c3c),
crust: rgb(0x232634),
surface0: rgb(0x414559),
surface1: rgb(0x51576d),
subtext0: rgb(0xa5adce),
text: rgb(0xc6d0f5),
pink: rgb(0xf4b8e4),
mauve: rgb(0xca9ee6),
sky: rgb(0x99d1db),
sapphire: rgb(0x85c1dc),
blue: rgb(0x8caaee),
lavender: rgb(0xbabbf1),
teal: rgb(0x81c8be),
green: rgb(0xa6d189),
peach: rgb(0xef9f76),
yellow: rgb(0xe5c890),
red: rgb(0xe78284),
light: false,
};
const MACCHIATO: Ramp = Ramp {
base: rgb(0x24273a),
mantle: rgb(0x1e2030),
crust: rgb(0x181926),
surface0: rgb(0x363a4f),
surface1: rgb(0x494d64),
subtext0: rgb(0xa5adcb),
text: rgb(0xcad3f5),
pink: rgb(0xf5bde6),
mauve: rgb(0xc6a0f6),
sky: rgb(0x91d7e3),
sapphire: rgb(0x7dc4e4),
blue: rgb(0x8aadf4),
lavender: rgb(0xb7bdf8),
teal: rgb(0x8bd5ca),
green: rgb(0xa6da95),
peach: rgb(0xf5a97f),
yellow: rgb(0xeed49f),
red: rgb(0xed8796),
light: false,
};
const MOCHA: Ramp = Ramp {
base: rgb(0x1e1e2e),
mantle: rgb(0x181825),
crust: rgb(0x11111b),
surface0: rgb(0x313244),
surface1: rgb(0x45475a),
subtext0: rgb(0xa6adc8),
text: rgb(0xcdd6f4),
pink: rgb(0xf5c2e7),
mauve: rgb(0xcba6f7),
sky: rgb(0x89dceb),
sapphire: rgb(0x74c7ec),
blue: rgb(0x89b4fa),
lavender: rgb(0xb4befe),
teal: rgb(0x94e2d5),
green: rgb(0xa6e3a1),
peach: rgb(0xfab387),
yellow: rgb(0xf9e2af),
red: rgb(0xf38ba8),
light: false,
};
impl Ramp {
const fn of(flavor: Flavor) -> Self {
match flavor {
Flavor::Proton => PROTON,
Flavor::Latte => LATTE,
Flavor::Frappe => FRAPPE,
Flavor::Macchiato => MACCHIATO,
Flavor::Mocha => MOCHA,
}
}
/// The accent, and the hue a gradient in it runs into.
///
/// The partners are neighbours on the wheel rather than complements: a
/// gradient across half the spectrum passes through a colour that belongs
/// to neither end, and on a seek bar that reads as a bug. The exception is
/// [`Accent::PinkSky`], which is the whole point of that entry.
const fn accents(&self, accent: Accent) -> (Color32, Color32) {
match accent {
Accent::Mauve => (self.mauve, self.blue),
Accent::Pink => (self.pink, self.mauve),
Accent::Sky => (self.sky, self.sapphire),
Accent::PinkSky => (self.pink, self.sky),
Accent::Lavender => (self.lavender, self.blue),
Accent::Blue => (self.blue, self.sapphire),
Accent::Teal => (self.teal, self.green),
Accent::Peach => (self.peach, self.yellow),
}
}
}
impl Palette {
/// The default, spelled out as a constant so the global has something to
/// hold before [`apply`] first runs — a `Ui` built during startup, or a
/// panic message painted before the engine exists, still gets colours.
const PROTON: Self = Self {
background: PROTON.base,
surface: PROTON.mantle,
sunken: PROTON.crust,
card: PROTON.surface0,
card_hover: PROTON.surface1,
border: rgb(0x262630),
text: PROTON.text,
muted: PROTON.subtext0,
accent: PROTON.mauve,
accent_alt: PROTON.blue,
accent_dim: rgb(0x5333ad),
on_accent: PROTON.text,
danger: PROTON.red,
light: false,
};
/// Resolve a choice into colours.
pub fn resolve(appearance: Appearance) -> Self {
let ramp = Ramp::of(appearance.flavor);
let (accent, partner) = ramp.accents(appearance.accent);
// Catppuccin's Latte accents are tuned to be *read*, on a light page,
// at body-text weight — which makes them mid-luminance, and a
// mid-luminance fill is one that neither black nor white sits on. They
// are taken down in value here, and only here: the hue and the
// saturation are Latte's, so it still looks like Latte, and a label on
// a button is legible.
let deepen = |color: Color32| {
if ramp.light {
mix(color, Color32::BLACK, 0.30)
} else {
color
}
};
let accent = deepen(accent);
let accent_alt = if appearance.gradients {
deepen(partner)
} else {
accent
};
// Ink that survives on the accent. The flavour's own reading first —
// pale text on a dark theme — and the opposite when that does not clear
// the bar, which on Catppuccin's dark flavours is most of the time:
// their accents are *pastel*, and white on #f5c2e7 is not a label.
//
// Latte's darkest colour is its body text, and even that is only #4c4f69,
// so the dark side is taken a third of the way to black.
let ink_dark = if ramp.light {
mix(ramp.text, Color32::BLACK, 0.35)
} else {
ramp.crust
};
let ink_light = if ramp.light { ramp.base } else { ramp.text };
let on_accent = if worst_contrast(accent, accent_alt, ink_light) >= MIN_CONTRAST {
ink_light
} else {
ink_dark
};
Self {
background: ramp.base,
surface: ramp.mantle,
sunken: ramp.crust,
card: ramp.surface0,
card_hover: ramp.surface1,
border: ramp.surface1,
text: ramp.text,
muted: ramp.subtext0,
accent,
accent_alt,
// Behind text, so it is the accent taken most of the way back to
// the page: a selection in a saturated hue is a selection nobody
// can read through.
accent_dim: mix(accent, ramp.base, if ramp.light { 0.55 } else { 0.45 }),
on_accent,
danger: ramp.red,
light: ramp.light,
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn every_flavour_and_accent_resolves_to_readable_ink() {
for flavor in Flavor::ALL {
for accent in Accent::ALL {
let palette = Palette::resolve(Appearance {
flavor,
accent,
gradients: true,
});
let ratio = worst_contrast(palette.accent, palette.accent_alt, palette.on_accent);
assert!(
ratio >= MIN_CONTRAST,
"{flavor:?}/{accent:?}: contrast {ratio:.2}",
);
}
}
}
#[test]
fn only_the_light_flavour_reports_itself_light() {
for flavor in Flavor::ALL {
let palette = Palette::resolve(Appearance {
flavor,
..Appearance::default()
});
assert_eq!(palette.light, flavor.is_light());
// And the page is on the right side of the middle either way.
assert_eq!(luminance(palette.background) > 0.5, flavor.is_light());
}
}
#[test]
fn turning_gradients_off_leaves_one_colour_to_draw() {
let flat = Palette::resolve(Appearance {
accent: Accent::PinkSky,
gradients: false,
..Appearance::default()
});
assert_eq!(flat.accent, flat.accent_alt);
}
#[test] #[test]
fn the_shipped_default_is_the_palette_that_was_here_before() { fn the_shipped_default_is_the_palette_that_was_here_before() {
let resolved = Palette::resolve(Appearance::default()); let resolved = Palette::resolve(Appearance::default());
@@ -795,11 +492,13 @@ mod tests {
#[test] #[test]
fn a_ramp_runs_from_one_colour_to_the_other() { fn a_ramp_runs_from_one_colour_to_the_other() {
// The blend itself is `pstr_core::appearance`'s and tested there; what
// is checked here is that the conversion in and out of `Color32` is
// not lossy at the ends.
let from = Color32::from_rgb(0, 0, 0); let from = Color32::from_rgb(0, 0, 0);
let to = Color32::from_rgb(255, 255, 255); let to = Color32::from_rgb(255, 255, 255);
assert_eq!(mix(from, to, 0.0), from); assert_eq!(mix(from, to, 0.0), from);
assert_eq!(mix(from, to, 1.0), to); assert_eq!(mix(from, to, 1.0), to);
assert!(luminance(mix(from, to, 0.5)) > luminance(from)); assert_ne!(mix(from, to, 0.5), from);
assert!(luminance(mix(from, to, 0.5)) < luminance(to));
} }
} }
+439
View File
@@ -151,6 +151,395 @@ pub fn save(dirs: &AppDirs, appearance: &Appearance) -> Result<()> {
write_json(&appearance_file(dirs), appearance) write_json(&appearance_file(dirs), appearance)
} }
/// One colour, 8 bits a channel, with no alpha.
///
/// Deliberately not a front end's colour type: this module resolves a choice
/// into colours for *both* clients, and neither egui's `Color32` nor Compose's
/// `Color` may appear here.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Rgb {
pub r: u8,
pub g: u8,
pub b: u8,
}
impl Rgb {
pub const BLACK: Self = Self::new(0, 0, 0);
pub const fn new(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b }
}
/// Packed as `0xAARRGGBB`, opaque — the form both Android and a hex literal
/// want.
pub const fn argb(self) -> u32 {
0xff00_0000 | ((self.r as u32) << 16) | ((self.g as u32) << 8) | self.b as u32
}
}
/// One channel from sRGB into linear light, as the sRGB transfer function
/// defines it. Blending has to happen here: mixing sRGB values directly darkens
/// the middle of a ramp between saturated hues, which is exactly where a
/// gradient is looked at.
fn linear(channel: u8) -> f32 {
let value = channel as f32 / 255.0;
if value <= 0.04045 {
value / 12.92
} else {
((value + 0.055) / 1.055).powf(2.4)
}
}
/// Back out of linear light, rounded to a channel.
fn gamma(value: f32) -> u8 {
let value = value.clamp(0.0, 1.0);
let encoded = if value <= 0.003_130_8 {
value * 12.92
} else {
1.055 * value.powf(1.0 / 2.4) - 0.055
};
(encoded * 255.0).round().clamp(0.0, 255.0) as u8
}
/// Blend two colours in linear light.
pub fn mix(from: Rgb, to: Rgb, t: f32) -> Rgb {
let blend = |from: u8, to: u8| gamma(linear(from) * (1.0 - t) + linear(to) * t);
Rgb::new(
blend(from.r, to.r),
blend(from.g, to.g),
blend(from.b, to.b),
)
}
/// Relative luminance, in linear light, as WCAG defines it.
pub fn luminance(color: Rgb) -> f32 {
0.2126 * linear(color.r) + 0.7152 * linear(color.g) + 0.0722 * linear(color.b)
}
/// WCAG contrast between two opaque colours: 1.0 for a colour against itself,
/// 21.0 for black against white.
fn contrast(one: Rgb, other: Rgb) -> f32 {
let (one, other) = (luminance(one), luminance(other));
(one.max(other) + 0.05) / (one.min(other) + 0.05)
}
/// The least contrast a label gets anywhere along a gradient between two
/// colours. Sampled rather than solved: the worst point is not always an end,
/// because the ink can be brighter than one end and darker than the other.
pub fn worst_contrast(from: Rgb, to: Rgb, ink: Rgb) -> f32 {
(0..=4)
.map(|step| contrast(mix(from, to, step as f32 / 4.0), ink))
.fold(f32::INFINITY, f32::min)
}
/// The least contrast the app will accept between a label and the fill under
/// it: WCAG AA for large text, which is what wears the accent — button labels,
/// a tab, a play glyph, never body copy.
pub const MIN_CONTRAST: f32 = 3.0;
/// Every colour a client draws with, resolved from a [`Flavor`] and an
/// [`Accent`].
///
/// `Copy`, and small, so reading it is a lock and a memcpy rather than anything
/// a drawing loop has to think about.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Palette {
/// Behind everything: the page.
pub background: Rgb,
/// The bars above and below it.
pub surface: Rgb,
/// The deepest colour in the flavour — text fields, wells.
pub sunken: Rgb,
/// Tiles, rows, forms.
pub card: Rgb,
pub card_hover: Rgb,
pub border: Rgb,
pub text: Rgb,
pub muted: Rgb,
/// The one strong colour.
pub accent: Rgb,
/// What a gradient in the accent runs into. Equal to [`Self::accent`] when
/// the accent is a single hue and gradients would have nothing to do.
pub accent_alt: Rgb,
/// The accent taken down into the background, for a pressed control and for
/// selection behind text.
pub accent_dim: Rgb,
/// Ink that stays readable on top of the accent. Chosen by the accent's
/// luminance, because Catppuccin's dark flavours have *pale* accents and
/// white-on-pastel is not readable at 13 px.
pub on_accent: Rgb,
pub danger: Rgb,
/// Whether this is a light theme.
pub light: bool,
}
/// One flavour's raw colours, named as Catppuccin names them.
///
/// Stored rather than computed: these are somebody else's palette, and the
/// point of using it is to use it exactly.
#[derive(Clone, Copy)]
struct Ramp {
base: Rgb,
mantle: Rgb,
crust: Rgb,
surface0: Rgb,
surface1: Rgb,
subtext0: Rgb,
text: Rgb,
pink: Rgb,
mauve: Rgb,
sky: Rgb,
sapphire: Rgb,
blue: Rgb,
lavender: Rgb,
teal: Rgb,
green: Rgb,
peach: Rgb,
yellow: Rgb,
red: Rgb,
light: bool,
}
const fn rgb(hex: u32) -> Rgb {
Rgb::new(
((hex >> 16) & 0xff) as u8,
((hex >> 8) & 0xff) as u8,
(hex & 0xff) as u8,
)
}
/// The palette this app shipped with, extended to the hues an accent can be.
///
/// Only `mauve` — Proton purple — and `red` are from the original; the rest are
/// chosen to sit on a near-black base at roughly the saturation that one does,
/// which is a good deal hotter than any Catppuccin flavour.
const PROTON: Ramp = Ramp {
base: rgb(0x0e0e12),
mantle: rgb(0x17171d),
crust: rgb(0x0a0a0d),
surface0: rgb(0x1e1e26),
surface1: rgb(0x2a2a35),
subtext0: rgb(0x8e8e9c),
text: rgb(0xeaeaf0),
pink: rgb(0xff5fbe),
mauve: rgb(0x7d4dff),
sky: rgb(0x4dd2ff),
sapphire: rgb(0x3a8fc4),
blue: rgb(0x3f6fe0),
lavender: rgb(0xa68cff),
teal: rgb(0x3fd6b8),
green: rgb(0x56d364),
peach: rgb(0xff9a4d),
yellow: rgb(0xffd166),
red: rgb(0xe05561),
light: false,
};
const LATTE: Ramp = Ramp {
base: rgb(0xeff1f5),
mantle: rgb(0xe6e9ef),
crust: rgb(0xdce0e8),
surface0: rgb(0xccd0da),
surface1: rgb(0xbcc0cc),
subtext0: rgb(0x6c6f85),
text: rgb(0x4c4f69),
pink: rgb(0xea76cb),
mauve: rgb(0x8839ef),
sky: rgb(0x04a5e5),
sapphire: rgb(0x209fb5),
blue: rgb(0x1e66f5),
lavender: rgb(0x7287fd),
teal: rgb(0x179299),
green: rgb(0x40a02b),
peach: rgb(0xfe640b),
yellow: rgb(0xdf8e1d),
red: rgb(0xd20f39),
light: true,
};
const FRAPPE: Ramp = Ramp {
base: rgb(0x303446),
mantle: rgb(0x292c3c),
crust: rgb(0x232634),
surface0: rgb(0x414559),
surface1: rgb(0x51576d),
subtext0: rgb(0xa5adce),
text: rgb(0xc6d0f5),
pink: rgb(0xf4b8e4),
mauve: rgb(0xca9ee6),
sky: rgb(0x99d1db),
sapphire: rgb(0x85c1dc),
blue: rgb(0x8caaee),
lavender: rgb(0xbabbf1),
teal: rgb(0x81c8be),
green: rgb(0xa6d189),
peach: rgb(0xef9f76),
yellow: rgb(0xe5c890),
red: rgb(0xe78284),
light: false,
};
const MACCHIATO: Ramp = Ramp {
base: rgb(0x24273a),
mantle: rgb(0x1e2030),
crust: rgb(0x181926),
surface0: rgb(0x363a4f),
surface1: rgb(0x494d64),
subtext0: rgb(0xa5adcb),
text: rgb(0xcad3f5),
pink: rgb(0xf5bde6),
mauve: rgb(0xc6a0f6),
sky: rgb(0x91d7e3),
sapphire: rgb(0x7dc4e4),
blue: rgb(0x8aadf4),
lavender: rgb(0xb7bdf8),
teal: rgb(0x8bd5ca),
green: rgb(0xa6da95),
peach: rgb(0xf5a97f),
yellow: rgb(0xeed49f),
red: rgb(0xed8796),
light: false,
};
const MOCHA: Ramp = Ramp {
base: rgb(0x1e1e2e),
mantle: rgb(0x181825),
crust: rgb(0x11111b),
surface0: rgb(0x313244),
surface1: rgb(0x45475a),
subtext0: rgb(0xa6adc8),
text: rgb(0xcdd6f4),
pink: rgb(0xf5c2e7),
mauve: rgb(0xcba6f7),
sky: rgb(0x89dceb),
sapphire: rgb(0x74c7ec),
blue: rgb(0x89b4fa),
lavender: rgb(0xb4befe),
teal: rgb(0x94e2d5),
green: rgb(0xa6e3a1),
peach: rgb(0xfab387),
yellow: rgb(0xf9e2af),
red: rgb(0xf38ba8),
light: false,
};
impl Ramp {
const fn of(flavor: Flavor) -> Self {
match flavor {
Flavor::Proton => PROTON,
Flavor::Latte => LATTE,
Flavor::Frappe => FRAPPE,
Flavor::Macchiato => MACCHIATO,
Flavor::Mocha => MOCHA,
}
}
/// The accent, and the hue a gradient in it runs into.
///
/// The partners are neighbours on the wheel rather than complements: a
/// gradient across half the spectrum passes through a colour that belongs
/// to neither end, and on a seek bar that reads as a bug. The exception is
/// [`Accent::PinkSky`], which is the whole point of that entry.
const fn accents(&self, accent: Accent) -> (Rgb, Rgb) {
match accent {
Accent::Mauve => (self.mauve, self.blue),
Accent::Pink => (self.pink, self.mauve),
Accent::Sky => (self.sky, self.sapphire),
Accent::PinkSky => (self.pink, self.sky),
Accent::Lavender => (self.lavender, self.blue),
Accent::Blue => (self.blue, self.sapphire),
Accent::Teal => (self.teal, self.green),
Accent::Peach => (self.peach, self.yellow),
}
}
}
impl Palette {
/// The default, spelled out as a constant so the global has something to
/// hold before [`apply`] first runs — a `Ui` built during startup, or a
/// panic message painted before the engine exists, still gets colours.
pub const PROTON: Self = Self {
background: PROTON.base,
surface: PROTON.mantle,
sunken: PROTON.crust,
card: PROTON.surface0,
card_hover: PROTON.surface1,
border: rgb(0x262630),
text: PROTON.text,
muted: PROTON.subtext0,
accent: PROTON.mauve,
accent_alt: PROTON.blue,
accent_dim: rgb(0x5333ad),
on_accent: PROTON.text,
danger: PROTON.red,
light: false,
};
/// Resolve a choice into colours.
pub fn resolve(appearance: Appearance) -> Self {
let ramp = Ramp::of(appearance.flavor);
let (accent, partner) = ramp.accents(appearance.accent);
// Catppuccin's Latte accents are tuned to be *read*, on a light page,
// at body-text weight — which makes them mid-luminance, and a
// mid-luminance fill is one that neither black nor white sits on. They
// are taken down in value here, and only here: the hue and the
// saturation are Latte's, so it still looks like Latte, and a label on
// a button is legible.
let deepen = |color: Rgb| {
if ramp.light {
mix(color, Rgb::BLACK, 0.30)
} else {
color
}
};
let accent = deepen(accent);
let accent_alt = if appearance.gradients {
deepen(partner)
} else {
accent
};
// Ink that survives on the accent. The flavour's own reading first —
// pale text on a dark theme — and the opposite when that does not clear
// the bar, which on Catppuccin's dark flavours is most of the time:
// their accents are *pastel*, and white on #f5c2e7 is not a label.
//
// Latte's darkest colour is its body text, and even that is only #4c4f69,
// so the dark side is taken a third of the way to black.
let ink_dark = if ramp.light {
mix(ramp.text, Rgb::BLACK, 0.35)
} else {
ramp.crust
};
let ink_light = if ramp.light { ramp.base } else { ramp.text };
let on_accent = if worst_contrast(accent, accent_alt, ink_light) >= MIN_CONTRAST {
ink_light
} else {
ink_dark
};
Self {
background: ramp.base,
surface: ramp.mantle,
sunken: ramp.crust,
card: ramp.surface0,
card_hover: ramp.surface1,
border: ramp.surface1,
text: ramp.text,
muted: ramp.subtext0,
accent,
accent_alt,
// Behind text, so it is the accent taken most of the way back to
// the page: a selection in a saturated hue is a selection nobody
// can read through.
accent_dim: mix(accent, ramp.base, if ramp.light { 0.55 } else { 0.45 }),
on_accent,
danger: ramp.red,
light: ramp.light,
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -209,4 +598,54 @@ mod tests {
assert_eq!(flavor.is_light(), flavor == Flavor::Latte); assert_eq!(flavor.is_light(), flavor == Flavor::Latte);
} }
} }
#[test]
fn every_flavour_and_accent_resolves_to_readable_ink() {
for flavor in Flavor::ALL {
for accent in Accent::ALL {
let palette = Palette::resolve(Appearance {
flavor,
accent,
gradients: true,
});
let ratio = worst_contrast(palette.accent, palette.accent_alt, palette.on_accent);
assert!(
ratio >= MIN_CONTRAST,
"{flavor:?}/{accent:?}: contrast {ratio:.2}",
);
}
}
}
#[test]
fn only_the_light_flavour_reports_itself_light() {
for flavor in Flavor::ALL {
let palette = Palette::resolve(Appearance {
flavor,
..Appearance::default()
});
assert_eq!(palette.light, flavor.is_light());
// And the page is on the right side of the middle either way.
assert_eq!(luminance(palette.background) > 0.5, flavor.is_light());
}
}
#[test]
fn turning_gradients_off_leaves_one_colour_to_draw() {
let flat = Palette::resolve(Appearance {
accent: Accent::PinkSky,
gradients: false,
..Appearance::default()
});
assert_eq!(flat.accent, flat.accent_alt);
}
#[test]
fn a_blend_stays_between_the_two_colours_it_blends() {
let (from, to) = (Rgb::new(0, 0, 0), Rgb::new(255, 255, 255));
assert_eq!(mix(from, to, 0.0), from);
assert_eq!(mix(from, to, 1.0), to);
assert!(luminance(mix(from, to, 0.5)) > luminance(from));
assert!(luminance(mix(from, to, 0.5)) < luminance(to));
}
} }
+10
View File
@@ -227,6 +227,16 @@ impl Catalog {
Ok(catalog) Ok(catalog)
} }
/// How many rows this connection has written since it was opened.
///
/// A cache key, not a statistic: anything derived from the catalog stays
/// valid exactly as long as this does not move. SQLite counts it for us, so
/// no write path has to remember to invalidate anything — which is the
/// failure mode a hand-maintained counter has.
pub fn writes(&self) -> u64 {
self.conn.total_changes()
}
fn migrate(&mut self) -> Result<()> { fn migrate(&mut self) -> Result<()> {
let version: i64 = self let version: i64 = self
.conn .conn
+483
View File
@@ -0,0 +1,483 @@
//! The chapters a file was muxed with, and what they are for.
//!
//! Anime releases mark their opening and ending as chapters, which is the whole
//! reason this exists: with them a player can offer to skip the ninety seconds
//! everyone skips, and without them it cannot.
//!
//! Nothing here talks to a player. Reading the chapter list out of mpv is
//! `pstr-player`'s job on the desktop and the JNI adapter's on Android; both
//! hand the result to [`roles`], so a chapter means the same thing on either.
/// One chapter of the file.
#[derive(Debug, Clone, PartialEq)]
pub struct Chapter {
/// Position in the list, which is what mpv's `chapter` property is set to.
pub index: i64,
/// The name the muxer gave it, if any.
pub title: Option<String>,
/// Where it starts, in seconds.
pub start: f64,
}
impl Chapter {
/// What to show in a list. A nameless chapter is still worth an entry —
/// it is a place in the file — so it gets its number.
pub fn label(&self) -> String {
match &self.title {
Some(title) => title.clone(),
None => format!("Chapter {}", self.index + 1),
}
}
/// What this chapter is, from its name alone.
///
/// Prefer [`roles`], which resolves the names that only mean one thing *in
/// context* — a chapter called `Intro` is an opening in one release and the
/// first ten minutes of the story in another.
pub fn role(&self) -> ChapterRole {
match Claim::of(self.title.as_deref().unwrap_or_default()) {
Claim::Opening => ChapterRole::Opening,
Claim::Ending => ChapterRole::Ending,
Claim::Preview => ChapterRole::Preview,
// Unresolved without the rest of the file: assume it is content,
// which is the reading that never skips anything.
_ => ChapterRole::Content,
}
}
}
/// The kinds of chapter worth treating differently from "part of the episode".
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChapterRole {
/// The opening titles.
Opening,
/// The ending titles.
Ending,
/// A preview of the next episode, after the ending.
Preview,
/// Anything else, including everything unnamed.
Content,
}
impl ChapterRole {
/// What a button offering to skip this should say. `None` for a chapter
/// nobody wants skipped.
pub fn skip_label(self) -> Option<&'static str> {
match self {
Self::Opening => Some("Skip opening"),
Self::Ending => Some("Skip ending"),
Self::Preview => Some("Skip preview"),
Self::Content => None,
}
}
/// Whether this is something the file could end on without the viewer
/// missing anything: credits and a trailer for next week.
///
/// An *opening* is not, even though it is skippable — a file does not end
/// with one, and treating it as tail would make a mis-tagged chapter
/// auto-advance out of the episode.
pub fn ends_the_episode(self) -> bool {
matches!(self, Self::Ending | Self::Preview)
}
}
/// What a chapter's *name* claims, before the rest of the file is consulted.
///
/// The two `Maybe` variants are the whole reason this is separate from
/// [`ChapterRole`]. `Intro` is the opening in one release and, in the first
/// episode of Oshi no Ko, a ten-minute introduction to the series that a viewer
/// must not be skipped out of. `Credits` and `Cast` are the ending in most
/// files and a mid-episode scene in a few. Neither can be settled by the name.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Claim {
Opening,
Ending,
Preview,
/// Reads like an opening, but only if the file has no real one and it is
/// the length of a theme song.
MaybeOpening,
/// Reads like credits, but only near the end of the file or at theme
/// length.
MaybeEnding,
Content,
}
/// How long an opening or ending theme runs.
///
/// Anime themes are ninety seconds by convention and rarely stray far. This is
/// the corroboration for a name that could go either way: a chapter called
/// `Intro` that runs eleven minutes is not a theme song, whatever it is called.
const THEME_SECONDS: std::ops::RangeInclusive<f64> = 45.0..=150.0;
/// Credits can run longer than a theme — a full cast roll over a still — so a
/// chapter that only *reads* like an ending gets this much room when it sits at
/// the end of the file.
const CREDITS_SECONDS: std::ops::RangeInclusive<f64> = 30.0..=300.0;
/// An opening that far into the file is not an opening. Cold opens run long in
/// a first episode, so this is generous.
const OPENING_BEFORE_SECONDS: f64 = 480.0;
/// Where the last part of a file starts, as a fraction of it. A chapter that
/// merely reads like credits is taken as credits past this point.
const TAIL_FRACTION: f64 = 0.75;
impl Claim {
/// Read a claim out of a chapter name.
///
/// Matched on whole words from the front, not on substrings: `Operation
/// Briefing` starts with `op` and is not an opening, `Ending Note` is not
/// an ending, and skipping a viewer out of either is a mistake they cannot
/// undo without finding the seek bar. Leading numbering (`01 - OP`, `1.
/// Opening`) is stepped over first, because plenty of muxes carry it.
fn of(title: &str) -> Self {
// Japanese names appear verbatim in releases from JP sources, and they
// are unambiguous where the English ones are not. Checked before the
// word split, which only knows about ASCII and would find no words at
// all in a name written entirely in kana.
if title.contains("オープニング") {
return Self::Opening;
}
if title.contains("エンディング") {
return Self::Ending;
}
if title.contains("予告") {
return Self::Preview;
}
let words = words(title);
let words: Vec<&str> = words.iter().map(String::as_str).collect();
let Some((head, rest)) = words.split_first() else {
return Self::Content;
};
let second = rest.first().copied().unwrap_or_default();
match (*head, second) {
// Two-word forms first: the second word is what tells `next
// episode` from `next`, and `end credits` from `end`.
("next", _) | ("nep", _) => Self::Preview,
("end" | "ending" | "closing", "credits") => Self::Ending,
("cast" | "staff", "roll" | "credits") => Self::Ending,
("opening" | "op", "theme" | "song" | "credits") => Self::Opening,
("ending" | "ed", "theme" | "song") => Self::Ending,
("op" | "opening" | "openning" | "ncop", _) => Self::Opening,
("ed" | "ending" | "nced" | "outro" | "endcard", _) => Self::Ending,
("preview" | "pv" | "trailer" | "teaser" | "yokoku", _) => Self::Preview,
("intro", _) => Self::MaybeOpening,
("credits" | "closing" | "cast" | "staff", _) => Self::MaybeEnding,
_ => Self::Content,
}
}
}
/// A chapter name as lowercase words, with any leading numbering dropped.
///
/// `OP1` and `ED2` keep their tag and lose their number — the digit is part of
/// the tag, not a word — while `02 - Opening` loses its leading `02` entirely.
fn words(title: &str) -> Vec<String> {
let mut words: Vec<String> = title
.trim()
.to_ascii_lowercase()
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|word| !word.is_empty())
.map(|word| {
word.trim_end_matches(|c: char| c.is_ascii_digit())
.to_string()
})
.collect();
// A leading token that was *only* digits is numbering, not a name.
if words.first().is_some_and(String::is_empty) {
words.remove(0);
}
words.retain(|word| !word.is_empty());
words
}
/// The role of every chapter in a file, with the file's own shape taken into
/// account.
///
/// This is the function to use. [`Chapter::role`] reads a name in isolation and
/// cannot tell an opening called `Intro` from ten minutes of story called the
/// same thing; here the other chapters and the running time settle it:
///
/// * a chapter that merely *reads* like an opening is one only when the file
/// has no chapter that says so outright, it runs the length of a theme song,
/// and it is near the front;
/// * a chapter that reads like credits is the ending when it runs to about that
/// length, or when it sits in the last quarter of the file.
///
/// Both rules fail *towards content*: not offering a skip costs a button, and
/// skipping the viewer out of the story costs them the story.
pub fn roles(chapters: &[Chapter], duration: Option<f64>) -> Vec<ChapterRole> {
let claims: Vec<Claim> = chapters
.iter()
.map(|chapter| Claim::of(chapter.title.as_deref().unwrap_or_default()))
.collect();
let has_real_opening = claims.contains(&Claim::Opening);
claims
.iter()
.enumerate()
.map(|(index, claim)| {
let length = chapter_end(chapters, index, duration)
.map(|end| end - chapters[index].start)
.filter(|length| *length > 0.0);
let start = chapters[index].start;
match claim {
Claim::Opening => ChapterRole::Opening,
Claim::Ending => ChapterRole::Ending,
Claim::Preview => ChapterRole::Preview,
Claim::MaybeOpening
if !has_real_opening
&& start <= OPENING_BEFORE_SECONDS
&& length.is_some_and(|length| THEME_SECONDS.contains(&length)) =>
{
ChapterRole::Opening
}
Claim::MaybeEnding
if length.is_some_and(|length| CREDITS_SECONDS.contains(&length))
|| duration.is_some_and(|total| start >= total * TAIL_FRACTION) =>
{
ChapterRole::Ending
}
_ => ChapterRole::Content,
}
})
.collect()
}
/// Where the run of chapters that ends the file — credits, a preview, and
/// nothing the viewer would miss — begins.
///
/// **The suffix, not the last chapter.** A release that puts a Part C after the
/// ending and a preview after that (Fullmetal Alchemist Brotherhood does,
/// exactly once) must give up only the preview, or auto-advancing skips a scene
/// that matters. Walking back from the end and stopping at the first chapter
/// that is content is what expresses that: `… ED · Part C · Preview` yields the
/// preview alone, while the ordinary `… ED · Preview` yields both.
///
/// `None` when the file ends on content, has no chapters, or is *entirely*
/// skippable — the last of which is a mis-tagged file, not an episode that is
/// all credits.
pub fn credits_start(chapters: &[Chapter], roles: &[ChapterRole]) -> Option<f64> {
let first = roles
.iter()
.rposition(|role| !role.ends_the_episode())
.map(|last_content| last_content + 1)?;
if first >= chapters.len() {
return None;
}
Some(chapters[first].start)
}
/// Where the chapter starting at `index` ends: the next one's start, or the end
/// of the file.
pub fn chapter_end(chapters: &[Chapter], index: usize, duration: Option<f64>) -> Option<f64> {
chapters
.get(index + 1)
.map(|next| next.start)
.or(duration)
.filter(|end| *end > 0.0)
}
/// The chapter a position falls in.
///
/// Linear rather than a binary search: a file has a dozen chapters, and this is
/// called once a frame at most.
pub fn chapter_at(chapters: &[Chapter], position: f64) -> Option<usize> {
chapters
.iter()
.rposition(|chapter| position + f64::EPSILON >= chapter.start)
}
#[cfg(test)]
mod tests {
use super::*;
fn chapter(index: i64, title: &str, start: f64) -> Chapter {
Chapter {
index,
title: Some(title.to_string()),
start,
}
}
#[test]
fn the_names_releases_actually_use_are_recognised() {
for (name, claim) in [
("OP", Claim::Opening),
("Opening", Claim::Opening),
("OP1 - Again", Claim::Opening),
("02 - Opening", Claim::Opening),
("Opening Song", Claim::Opening),
("NCOP", Claim::Opening),
("オープニング", Claim::Opening),
("ED", Claim::Ending),
("Ending 2", Claim::Ending),
("End Credits", Claim::Ending),
("Cast Roll", Claim::Ending),
("Endcard", Claim::Ending),
("エンディング", Claim::Ending),
("Preview", Claim::Preview),
("Next Episode", Claim::Preview),
("Next Episode Preview", Claim::Preview),
("予告", Claim::Preview),
] {
assert_eq!(Claim::of(name), claim, "{name}");
}
}
#[test]
fn a_chapter_that_merely_starts_with_those_letters_is_not_one() {
// The reason this matches words and not prefixes: skipping the viewer
// out of "Operation Briefing" would be a bug they cannot undo without
// finding the seek bar.
for name in ["Operation Briefing", "Editor's cut", "Part A", "Part C", ""] {
assert_eq!(Claim::of(name), Claim::Content, "{name}");
}
}
/// The names that cannot be settled by reading them.
#[test]
fn an_ambiguous_name_is_not_decided_by_itself() {
assert_eq!(Claim::of("Intro"), Claim::MaybeOpening);
assert_eq!(Claim::of("Credits"), Claim::MaybeEnding);
assert_eq!(Claim::of("Cast"), Claim::MaybeEnding);
// And a chapter read in isolation never resolves to a skip.
assert_eq!(chapter(0, "Intro", 0.0).role(), ChapterRole::Content);
}
/// **The first episode of Oshi no Ko.** No opening, no ending — an eighty
/// minute episode that begins with a long `Intro` that is the story, and
/// ends with a cast scroll. Offering "skip opening" over the first ninety
/// seconds of *that* skips the beginning of the series.
#[test]
fn a_long_intro_is_the_episode_and_not_an_opening() {
let chapters = vec![
chapter(0, "Intro", 0.0),
chapter(1, "Part A", 700.0),
chapter(2, "Part B", 2400.0),
chapter(3, "Cast", 4680.0),
];
let roles = roles(&chapters, Some(4800.0));
assert_eq!(
roles[0],
ChapterRole::Content,
"eleven minutes is not a theme"
);
assert_eq!(
roles[3],
ChapterRole::Ending,
"a cast roll at the end is one"
);
// Only the cast scroll is the tail; the episode is not skipped into.
assert_eq!(credits_start(&chapters, &roles), Some(4680.0));
}
/// The same name in the release that uses it for the theme song: ninety
/// seconds, near the front, and no other opening in the file.
#[test]
fn a_theme_length_intro_near_the_front_is_an_opening() {
let chapters = vec![
chapter(0, "Avant", 0.0),
chapter(1, "Intro", 90.0),
chapter(2, "Part A", 180.0),
];
assert_eq!(roles(&chapters, Some(1440.0))[1], ChapterRole::Opening);
// …but not when the file already says which chapter the opening is.
let chapters = vec![
chapter(0, "Intro", 0.0),
chapter(1, "OP", 90.0),
chapter(2, "Part A", 180.0),
];
let roles = roles(&chapters, Some(1440.0));
assert_eq!(roles[0], ChapterRole::Content);
assert_eq!(roles[1], ChapterRole::Opening);
}
/// The ordinary shape: the credits and the trailer for next week are one
/// run at the end, and the countdown starts where the credits do.
#[test]
fn the_tail_is_the_whole_run_of_credits_and_preview() {
let chapters = vec![
chapter(0, "OP", 0.0),
chapter(1, "Part A", 90.0),
chapter(2, "Part B", 700.0),
chapter(3, "ED", 1320.0),
chapter(4, "Preview", 1410.0),
];
let roles = roles(&chapters, Some(1440.0));
assert_eq!(credits_start(&chapters, &roles), Some(1320.0));
}
/// **Episode 46 of Fullmetal Alchemist Brotherhood.** A Part C *after* the
/// ending, and the preview after that. Only the preview may be skipped —
/// treating everything from the ending on as tail would drop a scene.
#[test]
fn content_after_the_ending_keeps_the_ending_out_of_the_tail() {
let chapters = vec![
chapter(0, "Part A", 0.0),
chapter(1, "Part B", 700.0),
chapter(2, "ED", 1260.0),
chapter(3, "Part C", 1350.0),
chapter(4, "Preview", 1400.0),
];
let roles = roles(&chapters, Some(1440.0));
assert_eq!(roles[2], ChapterRole::Ending, "it is still an ending");
assert_eq!(credits_start(&chapters, &roles), Some(1400.0));
}
/// A file that ends on the story has no tail at all, and one with no
/// chapters has nothing to read.
#[test]
fn a_file_that_ends_on_content_has_no_tail() {
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "Part A", 90.0)];
let roles = roles(&chapters, Some(1440.0));
assert_eq!(credits_start(&chapters, &roles), None);
assert_eq!(credits_start(&[], &[]), None);
// Everything skippable is a mis-tagged file, not an episode that is
// entirely credits: auto-advancing out of it at second zero would be
// worse than doing nothing.
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "ED", 90.0)];
let roles = vec![ChapterRole::Ending, ChapterRole::Ending];
assert_eq!(credits_start(&chapters, &roles), None);
}
#[test]
fn a_position_lands_in_the_chapter_that_contains_it() {
let chapters = vec![
chapter(0, "Intro", 0.0),
chapter(1, "OP", 24.0),
chapter(2, "Part A", 114.0),
];
assert_eq!(chapter_at(&chapters, 0.0), Some(0));
assert_eq!(chapter_at(&chapters, 23.9), Some(0));
// Exactly on a boundary belongs to the chapter that starts there,
// which is what makes "skip" land *out* of the opening rather than at
// its last frame.
assert_eq!(chapter_at(&chapters, 24.0), Some(1));
assert_eq!(chapter_at(&chapters, 3000.0), Some(2));
}
#[test]
fn a_chapter_before_the_first_one_starts_is_in_no_chapter() {
let chapters = vec![chapter(0, "OP", 24.0)];
assert_eq!(chapter_at(&chapters, 0.0), None);
}
#[test]
fn the_last_chapter_ends_where_the_file_does() {
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "Part A", 90.0)];
assert_eq!(chapter_end(&chapters, 0, Some(1440.0)), Some(90.0));
assert_eq!(chapter_end(&chapters, 1, Some(1440.0)), Some(1440.0));
// No duration yet and nothing after it: there is nowhere to skip to,
// and a button that seeks to zero would be worse than no button.
assert_eq!(chapter_end(&chapters, 1, None), None);
}
}
+1
View File
@@ -7,6 +7,7 @@
pub mod appearance; pub mod appearance;
pub mod catalog; pub mod catalog;
pub mod chapters;
pub mod config; pub mod config;
pub mod error; pub mod error;
pub mod library; pub mod library;
+25
View File
@@ -18,6 +18,11 @@ use crate::error::Result;
/// referenced here because `pstr-core` does not depend on the player. /// referenced here because `pstr-core` does not depend on the player.
const MAX_VOLUME: f64 = 100.0; const MAX_VOLUME: f64 = 100.0;
/// The range of playback rates offered. Past these, audio stops being speech
/// and video stops being watchable, and mpv's own resampler gives up first.
const MIN_SPEED: f64 = 0.25;
const MAX_SPEED: f64 = 4.0;
/// How playback should sound, and in which language. /// How playback should sound, and in which language.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
// A file written by an older version, or hand-edited, is missing fields rather // A file written by an older version, or hand-edited, is missing fields rather
@@ -43,6 +48,18 @@ pub struct PlaybackPrefs {
/// does and what a season of anything is watched like. Off is one checkbox /// does and what a season of anything is watched like. Off is one checkbox
/// away for someone who falls asleep to it. /// away for someone who falls asleep to it.
pub autoplay_next: bool, pub autoplay_next: bool,
/// Whether an opening or ending is seeked past rather than offered.
///
/// Off by default, and deliberately: chapter names are a guess about a file
/// somebody else muxed, and a wrong guess that only greys out a button
/// costs nothing while a wrong guess that seeks costs the scene.
pub auto_skip: bool,
/// How fast playback runs, 1.0 being the file's own rate.
///
/// Kept across episodes deliberately: someone who watches at 1.25 watches
/// everything at 1.25, and a speed that reset every episode would be a
/// setting nobody could use.
pub speed: f64,
} }
impl Default for PlaybackPrefs { impl Default for PlaybackPrefs {
@@ -54,6 +71,8 @@ impl Default for PlaybackPrefs {
subtitle_language: None, subtitle_language: None,
subtitles: true, subtitles: true,
autoplay_next: true, autoplay_next: true,
auto_skip: false,
speed: 1.0,
} }
} }
} }
@@ -68,6 +87,10 @@ impl PlaybackPrefs {
self.volume = MAX_VOLUME; self.volume = MAX_VOLUME;
} }
self.volume = self.volume.clamp(0.0, MAX_VOLUME); self.volume = self.volume.clamp(0.0, MAX_VOLUME);
if !self.speed.is_finite() {
self.speed = 1.0;
}
self.speed = self.speed.clamp(MIN_SPEED, MAX_SPEED);
self.audio_language = self.audio_language.filter(|tag| !tag.trim().is_empty()); self.audio_language = self.audio_language.filter(|tag| !tag.trim().is_empty());
self.subtitle_language = self.subtitle_language.filter(|tag| !tag.trim().is_empty()); self.subtitle_language = self.subtitle_language.filter(|tag| !tag.trim().is_empty());
self self
@@ -124,6 +147,8 @@ mod tests {
subtitle_language: Some("eng".into()), subtitle_language: Some("eng".into()),
subtitles: true, subtitles: true,
autoplay_next: false, autoplay_next: false,
auto_skip: true,
speed: 1.5,
}; };
save(&dirs, &prefs).unwrap(); save(&dirs, &prefs).unwrap();
assert_eq!(load(&dirs).unwrap(), prefs); assert_eq!(load(&dirs).unwrap(), prefs);
+177
View File
@@ -160,6 +160,49 @@ impl ShareStore {
Ok(share) Ok(share)
} }
/// Re-supply a configured share's secrets, keeping everything else.
///
/// A secret store can lose the ability to read what it wrote: an Android
/// Keystore key is invalidated by a lockscreen change or a device restore,
/// and a desktop keyring can be reset. After that every open of the share
/// fails — and so, often, does removing it, because removal deletes a
/// secret the store can no longer touch. Re-entering the link is the way
/// back that does not cost the catalog and the offline files.
///
/// The link must be the same one: a different token is a different share,
/// and quietly repointing an existing library at it would leave every
/// catalog row describing files that are no longer there.
pub fn replace_secrets(
&self,
id: &str,
url: &str,
custom_password: Option<&str>,
) -> Result<Share> {
let token = token_from_url(url)?;
let mut shares = self.list()?;
let share = shares
.iter_mut()
.find(|share| share.id == id)
.ok_or_else(|| Error::NotFound(format!("no share with id {id}")))?;
if share.token != token {
return Err(Error::Config(
"that link is for a different share; add it as a new one".to_owned(),
));
}
let secrets = ShareSecrets {
url: url.to_string(),
custom_password: custom_password
.filter(|password| !password.is_empty())
.map(str::to_string),
};
self.store_secrets(id, &secrets)?;
share.has_custom_password = secrets.custom_password.is_some();
let updated = share.clone();
write_json(&self.dirs.shares_file(), &shares)?;
Ok(updated)
}
/// Forget a share and its secrets. /// Forget a share and its secrets.
/// ///
/// The config entry goes first here, for the mirror-image reason: if the /// The config entry goes first here, for the mirror-image reason: if the
@@ -223,10 +266,28 @@ impl SharedLibrary {
/// the whole library — one revoked link should not make the other three /// the whole library — one revoked link should not make the other three
/// unwatchable. /// unwatchable.
pub async fn open_all(store: &ShareStore) -> Result<(Self, Vec<(Share, Error)>)> { pub async fn open_all(store: &ShareStore) -> Result<(Self, Vec<(Share, Error)>)> {
Self::open_all_reusing(store, BTreeMap::new()).await
}
/// Open every configured share, reusing the clients in `reusable`.
///
/// Adding or removing one link should not cost a fresh handshake for the
/// shares that were already open, so a caller that still holds them can
/// hand them back here. A share whose stored secrets changed must *not*
/// appear in `reusable` — its old client authenticates against the old
/// link.
pub async fn open_all_reusing(
store: &ShareStore,
mut reusable: BTreeMap<String, ProtonDrivePublicLinkClient>,
) -> Result<(Self, Vec<(Share, Error)>)> {
let mut clients = BTreeMap::new(); let mut clients = BTreeMap::new();
let mut failures = Vec::new(); let mut failures = Vec::new();
for share in store.list()? { for share in store.list()? {
if let Some(client) = reusable.remove(&share.id) {
clients.insert(share.id, client);
continue;
}
match store.open(&share).await { match store.open(&share).await {
Ok(client) => { Ok(client) => {
clients.insert(share.id.clone(), client); clients.insert(share.id.clone(), client);
@@ -238,6 +299,23 @@ impl SharedLibrary {
Ok((Self { clients }, failures)) Ok((Self { clients }, failures))
} }
/// The opened clients, minus `dropping`, ready to hand to
/// [`SharedLibrary::open_all_reusing`].
///
/// The excluded share is the one whose credentials or membership just
/// changed; leaving it out is what makes a removed share's live client
/// unreachable rather than merely stale.
pub fn reusable_clients(
&self,
dropping: &str,
) -> BTreeMap<String, ProtonDrivePublicLinkClient> {
self.clients
.iter()
.filter(|(id, _)| id.as_str() != dropping)
.map(|(id, client)| (id.clone(), client.clone()))
.collect()
}
/// The client for one share. /// The client for one share.
pub fn client(&self, share_id: &str) -> Option<&ProtonDrivePublicLinkClient> { pub fn client(&self, share_id: &str) -> Option<&ProtonDrivePublicLinkClient> {
self.clients.get(share_id) self.clients.get(share_id)
@@ -396,6 +474,105 @@ mod tests {
} }
} }
/// A store that has forgotten how to read what it wrote, which is what an
/// invalidated Android Keystore key looks like from Rust.
#[derive(Default)]
struct Unreadable {
entries: std::sync::Mutex<BTreeMap<String, String>>,
readable: std::sync::atomic::AtomicBool,
}
impl SecretStore for Unreadable {
fn set(&self, key: &str, value: &str) -> Result<()> {
self.entries
.lock()
.unwrap()
.insert(key.to_owned(), value.to_owned());
self.readable
.store(true, std::sync::atomic::Ordering::Relaxed);
Ok(())
}
fn get(&self, key: &str) -> Result<Option<String>> {
if !self.readable.load(std::sync::atomic::Ordering::Relaxed) {
return Err(Error::Config("key permanently invalidated".into()));
}
Ok(self.entries.lock().unwrap().get(key).cloned())
}
fn delete(&self, key: &str) -> Result<()> {
self.entries.lock().unwrap().remove(key);
Ok(())
}
}
fn store_in(name: &str) -> (ShareStore, Arc<Unreadable>) {
let root = std::env::temp_dir().join(format!("pstr-shares-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let dirs = AppDirs::from_paths(root.join("config"), root.join("data"), root.join("cache"))
.expect("app directories");
let secrets = Arc::new(Unreadable::default());
(
ShareStore::with_secret_store(dirs, secrets.clone()),
secrets,
)
}
/// The way back from an unreadable secret store. The share keeps its id, so
/// the catalog rows and offline files keyed by it stay valid — which is the
/// whole reason this exists rather than Remove-and-re-add.
#[test]
fn a_re_entered_link_restores_a_share_whose_secret_cannot_be_read() {
let (store, secrets) = store_in("repair");
let url = "https://drive.proton.me/urls/ABC123#s3cr3t";
let added = store.add("Library", url, None).expect("add");
secrets
.readable
.store(false, std::sync::atomic::Ordering::Relaxed);
assert!(store.load_secrets(&added.id).is_err());
let repaired = store
.replace_secrets(&added.id, url, Some("new-password"))
.expect("replace");
assert_eq!(repaired.id, added.id);
assert!(repaired.has_custom_password);
assert!(
store
.list()
.expect("list")
.iter()
.any(|share| share.id == added.id && share.has_custom_password)
);
}
/// A different link is a different share. Repointing this one at it would
/// leave every catalog row and offline file describing files that are not
/// there.
#[test]
fn a_re_entered_link_for_a_different_share_is_refused() {
let (store, _) = store_in("repair-mismatch");
let added = store
.add(
"Library",
"https://drive.proton.me/urls/ABC123#s3cr3t",
None,
)
.expect("add");
let error = store
.replace_secrets(
&added.id,
"https://drive.proton.me/urls/DEF456#s3cr3t",
None,
)
.expect_err("a different token must be refused");
assert!(
error.to_string().contains("different share"),
"says what is wrong: {error}"
);
}
/// The id must be derivable from the token alone — deriving it from the URL /// The id must be derivable from the token alone — deriving it from the URL
/// would mix a secret into the config file. /// would mix a secret into the config file.
#[test] #[test]
+48
View File
@@ -223,6 +223,54 @@ impl MetadataService {
Ok(episodes) Ok(episodes)
} }
/// The episode list for a whole title, with a failure treated as an empty
/// one.
///
/// Deliberately not fatal to the title it belongs to: a poster and a
/// synopsis that arrived are worth keeping even when the episode request
/// was the one that hit the rate limit. The empty result is not cached as
/// an answer — the next match run asks again — because the only thing that
/// triggers one is a viewer pressing the button.
///
/// The match itself only ever answers for *one* entry, and on a provider
/// that files each sequel separately — AniList — that entry is season one.
/// So each further season of the title is searched for by name and its
/// episodes are tagged with the season they came from; without that,
/// seasons two and three have no episode names at all. See
/// [`MetadataService::season_episodes`].
pub async fn title_episodes(
&self,
title: &Title,
found: &TitleMetadata,
) -> Vec<EpisodeMetadata> {
let mut episodes = self
.episodes(found)
.await
.inspect_err(|error| tracing::warn!("episodes for {}: {error}", found.name))
.unwrap_or_default();
if !self.splits_seasons() {
return episodes;
}
// Season one is what the title's own match already answered for.
let later: Vec<u32> = title
.seasons
.iter()
.filter_map(|season| season.number)
.filter(|number| *number > 1)
.collect();
for season in later {
match self.season_episodes(title, season).await {
Ok(found) => episodes.extend(found),
Err(error) => {
tracing::warn!("episodes for {} season {season}: {error}", title.name);
}
}
}
episodes
}
/// Whether this provider files a sequel as its own entry, so that a title /// Whether this provider files a sequel as its own entry, so that a title
/// with several seasons has to be searched for once per season. /// with several seasons has to be searched for once per season.
pub fn splits_seasons(&self) -> bool { pub fn splits_seasons(&self) -> bool {
+3
View File
@@ -9,6 +9,9 @@ rust-version.workspace = true
authors.workspace = true authors.workspace = true
[dependencies] [dependencies]
# For `chapters`, which Android reaches through its own bridge and so cannot
# live here. Already in the tree via pstr-stream.
pstr-core.workspace = true
pstr-stream.workspace = true pstr-stream.workspace = true
tokio.workspace = true tokio.workspace = true
thiserror.workspace = true thiserror.workspace = true
+9 -478
View File
@@ -1,280 +1,19 @@
//! The chapters a file was muxed with, and what they are for. //! Reading the chapter list out of the player.
//!
//! The chapters themselves — what a name claims, what the rest of the file
//! settles, where the tail begins — live in [`pstr_core::chapters`], because
//! Android reads its chapter list through its own JNI adapter and must reach
//! the same verdict as this crate does. Only the mpv read is here.
//! //!
//! Read the same way as the track list — `chapter-list/N/title`, one //! Read the same way as the track list — `chapter-list/N/title`, one
//! sub-property at a time — for the same reason: those are plain strings and //! sub-property at a time — for the same reason: those are plain strings and
//! doubles, and a node walk would buy nothing. //! doubles, and a node walk would buy nothing.
//!
//! Anime releases mark their opening and ending as chapters, which is the whole
//! reason this exists: with them a player can offer to skip the ninety seconds
//! everyone skips, and without them it cannot.
use libmpv2::Mpv; use libmpv2::Mpv;
/// One chapter of the file. pub use pstr_core::chapters::{
#[derive(Debug, Clone, PartialEq)] Chapter, ChapterRole, chapter_at, chapter_end, credits_start, roles,
pub struct Chapter { };
/// Position in the list, which is what mpv's `chapter` property is set to.
pub index: i64,
/// The name the muxer gave it, if any.
pub title: Option<String>,
/// Where it starts, in seconds.
pub start: f64,
}
impl Chapter {
/// What to show in a list. A nameless chapter is still worth an entry —
/// it is a place in the file — so it gets its number.
pub fn label(&self) -> String {
match &self.title {
Some(title) => title.clone(),
None => format!("Chapter {}", self.index + 1),
}
}
/// What this chapter is, from its name alone.
///
/// Prefer [`roles`], which resolves the names that only mean one thing *in
/// context* — a chapter called `Intro` is an opening in one release and the
/// first ten minutes of the story in another.
pub fn role(&self) -> ChapterRole {
match Claim::of(self.title.as_deref().unwrap_or_default()) {
Claim::Opening => ChapterRole::Opening,
Claim::Ending => ChapterRole::Ending,
Claim::Preview => ChapterRole::Preview,
// Unresolved without the rest of the file: assume it is content,
// which is the reading that never skips anything.
_ => ChapterRole::Content,
}
}
}
/// The kinds of chapter worth treating differently from "part of the episode".
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChapterRole {
/// The opening titles.
Opening,
/// The ending titles.
Ending,
/// A preview of the next episode, after the ending.
Preview,
/// Anything else, including everything unnamed.
Content,
}
impl ChapterRole {
/// What a button offering to skip this should say. `None` for a chapter
/// nobody wants skipped.
pub fn skip_label(self) -> Option<&'static str> {
match self {
Self::Opening => Some("Skip opening"),
Self::Ending => Some("Skip ending"),
Self::Preview => Some("Skip preview"),
Self::Content => None,
}
}
/// Whether this is something the file could end on without the viewer
/// missing anything: credits and a trailer for next week.
///
/// An *opening* is not, even though it is skippable — a file does not end
/// with one, and treating it as tail would make a mis-tagged chapter
/// auto-advance out of the episode.
pub fn ends_the_episode(self) -> bool {
matches!(self, Self::Ending | Self::Preview)
}
}
/// What a chapter's *name* claims, before the rest of the file is consulted.
///
/// The two `Maybe` variants are the whole reason this is separate from
/// [`ChapterRole`]. `Intro` is the opening in one release and, in the first
/// episode of Oshi no Ko, a ten-minute introduction to the series that a viewer
/// must not be skipped out of. `Credits` and `Cast` are the ending in most
/// files and a mid-episode scene in a few. Neither can be settled by the name.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Claim {
Opening,
Ending,
Preview,
/// Reads like an opening, but only if the file has no real one and it is
/// the length of a theme song.
MaybeOpening,
/// Reads like credits, but only near the end of the file or at theme
/// length.
MaybeEnding,
Content,
}
/// How long an opening or ending theme runs.
///
/// Anime themes are ninety seconds by convention and rarely stray far. This is
/// the corroboration for a name that could go either way: a chapter called
/// `Intro` that runs eleven minutes is not a theme song, whatever it is called.
const THEME_SECONDS: std::ops::RangeInclusive<f64> = 45.0..=150.0;
/// Credits can run longer than a theme — a full cast roll over a still — so a
/// chapter that only *reads* like an ending gets this much room when it sits at
/// the end of the file.
const CREDITS_SECONDS: std::ops::RangeInclusive<f64> = 30.0..=300.0;
/// An opening that far into the file is not an opening. Cold opens run long in
/// a first episode, so this is generous.
const OPENING_BEFORE_SECONDS: f64 = 480.0;
/// Where the last part of a file starts, as a fraction of it. A chapter that
/// merely reads like credits is taken as credits past this point.
const TAIL_FRACTION: f64 = 0.75;
impl Claim {
/// Read a claim out of a chapter name.
///
/// Matched on whole words from the front, not on substrings: `Operation
/// Briefing` starts with `op` and is not an opening, `Ending Note` is not
/// an ending, and skipping a viewer out of either is a mistake they cannot
/// undo without finding the seek bar. Leading numbering (`01 - OP`, `1.
/// Opening`) is stepped over first, because plenty of muxes carry it.
fn of(title: &str) -> Self {
// Japanese names appear verbatim in releases from JP sources, and they
// are unambiguous where the English ones are not. Checked before the
// word split, which only knows about ASCII and would find no words at
// all in a name written entirely in kana.
if title.contains("オープニング") {
return Self::Opening;
}
if title.contains("エンディング") {
return Self::Ending;
}
if title.contains("予告") {
return Self::Preview;
}
let words = words(title);
let words: Vec<&str> = words.iter().map(String::as_str).collect();
let Some((head, rest)) = words.split_first() else {
return Self::Content;
};
let second = rest.first().copied().unwrap_or_default();
match (*head, second) {
// Two-word forms first: the second word is what tells `next
// episode` from `next`, and `end credits` from `end`.
("next", _) | ("nep", _) => Self::Preview,
("end" | "ending" | "closing", "credits") => Self::Ending,
("cast" | "staff", "roll" | "credits") => Self::Ending,
("opening" | "op", "theme" | "song" | "credits") => Self::Opening,
("ending" | "ed", "theme" | "song") => Self::Ending,
("op" | "opening" | "openning" | "ncop", _) => Self::Opening,
("ed" | "ending" | "nced" | "outro" | "endcard", _) => Self::Ending,
("preview" | "pv" | "trailer" | "teaser" | "yokoku", _) => Self::Preview,
("intro", _) => Self::MaybeOpening,
("credits" | "closing" | "cast" | "staff", _) => Self::MaybeEnding,
_ => Self::Content,
}
}
}
/// A chapter name as lowercase words, with any leading numbering dropped.
///
/// `OP1` and `ED2` keep their tag and lose their number — the digit is part of
/// the tag, not a word — while `02 - Opening` loses its leading `02` entirely.
fn words(title: &str) -> Vec<String> {
let mut words: Vec<String> = title
.trim()
.to_ascii_lowercase()
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|word| !word.is_empty())
.map(|word| {
word.trim_end_matches(|c: char| c.is_ascii_digit())
.to_string()
})
.collect();
// A leading token that was *only* digits is numbering, not a name.
if words.first().is_some_and(String::is_empty) {
words.remove(0);
}
words.retain(|word| !word.is_empty());
words
}
/// The role of every chapter in a file, with the file's own shape taken into
/// account.
///
/// This is the function to use. [`Chapter::role`] reads a name in isolation and
/// cannot tell an opening called `Intro` from ten minutes of story called the
/// same thing; here the other chapters and the running time settle it:
///
/// * a chapter that merely *reads* like an opening is one only when the file
/// has no chapter that says so outright, it runs the length of a theme song,
/// and it is near the front;
/// * a chapter that reads like credits is the ending when it runs to about that
/// length, or when it sits in the last quarter of the file.
///
/// Both rules fail *towards content*: not offering a skip costs a button, and
/// skipping the viewer out of the story costs them the story.
pub fn roles(chapters: &[Chapter], duration: Option<f64>) -> Vec<ChapterRole> {
let claims: Vec<Claim> = chapters
.iter()
.map(|chapter| Claim::of(chapter.title.as_deref().unwrap_or_default()))
.collect();
let has_real_opening = claims.contains(&Claim::Opening);
claims
.iter()
.enumerate()
.map(|(index, claim)| {
let length = chapter_end(chapters, index, duration)
.map(|end| end - chapters[index].start)
.filter(|length| *length > 0.0);
let start = chapters[index].start;
match claim {
Claim::Opening => ChapterRole::Opening,
Claim::Ending => ChapterRole::Ending,
Claim::Preview => ChapterRole::Preview,
Claim::MaybeOpening
if !has_real_opening
&& start <= OPENING_BEFORE_SECONDS
&& length.is_some_and(|length| THEME_SECONDS.contains(&length)) =>
{
ChapterRole::Opening
}
Claim::MaybeEnding
if length.is_some_and(|length| CREDITS_SECONDS.contains(&length))
|| duration.is_some_and(|total| start >= total * TAIL_FRACTION) =>
{
ChapterRole::Ending
}
_ => ChapterRole::Content,
}
})
.collect()
}
/// Where the run of chapters that ends the file — credits, a preview, and
/// nothing the viewer would miss — begins.
///
/// **The suffix, not the last chapter.** A release that puts a Part C after the
/// ending and a preview after that (Fullmetal Alchemist Brotherhood does,
/// exactly once) must give up only the preview, or auto-advancing skips a scene
/// that matters. Walking back from the end and stopping at the first chapter
/// that is content is what expresses that: `… ED · Part C · Preview` yields the
/// preview alone, while the ordinary `… ED · Preview` yields both.
///
/// `None` when the file ends on content, has no chapters, or is *entirely*
/// skippable — the last of which is a mis-tagged file, not an episode that is
/// all credits.
pub fn credits_start(chapters: &[Chapter], roles: &[ChapterRole]) -> Option<f64> {
let first = roles
.iter()
.rposition(|role| !role.ends_the_episode())
.map(|last_content| last_content + 1)?;
if first >= chapters.len() {
return None;
}
Some(chapters[first].start)
}
/// Every chapter of the file mpv currently has open. /// Every chapter of the file mpv currently has open.
pub(crate) fn read(mpv: &Mpv) -> Vec<Chapter> { pub(crate) fn read(mpv: &Mpv) -> Vec<Chapter> {
@@ -293,211 +32,3 @@ pub(crate) fn read(mpv: &Mpv) -> Vec<Chapter> {
}) })
.collect() .collect()
} }
/// Where the chapter starting at `index` ends: the next one's start, or the end
/// of the file.
pub fn chapter_end(chapters: &[Chapter], index: usize, duration: Option<f64>) -> Option<f64> {
chapters
.get(index + 1)
.map(|next| next.start)
.or(duration)
.filter(|end| *end > 0.0)
}
/// The chapter a position falls in.
///
/// Linear rather than a binary search: a file has a dozen chapters, and this is
/// called once a frame at most.
pub fn chapter_at(chapters: &[Chapter], position: f64) -> Option<usize> {
chapters
.iter()
.rposition(|chapter| position + f64::EPSILON >= chapter.start)
}
#[cfg(test)]
mod tests {
use super::*;
fn chapter(index: i64, title: &str, start: f64) -> Chapter {
Chapter {
index,
title: Some(title.to_string()),
start,
}
}
#[test]
fn the_names_releases_actually_use_are_recognised() {
for (name, claim) in [
("OP", Claim::Opening),
("Opening", Claim::Opening),
("OP1 - Again", Claim::Opening),
("02 - Opening", Claim::Opening),
("Opening Song", Claim::Opening),
("NCOP", Claim::Opening),
("オープニング", Claim::Opening),
("ED", Claim::Ending),
("Ending 2", Claim::Ending),
("End Credits", Claim::Ending),
("Cast Roll", Claim::Ending),
("Endcard", Claim::Ending),
("エンディング", Claim::Ending),
("Preview", Claim::Preview),
("Next Episode", Claim::Preview),
("Next Episode Preview", Claim::Preview),
("予告", Claim::Preview),
] {
assert_eq!(Claim::of(name), claim, "{name}");
}
}
#[test]
fn a_chapter_that_merely_starts_with_those_letters_is_not_one() {
// The reason this matches words and not prefixes: skipping the viewer
// out of "Operation Briefing" would be a bug they cannot undo without
// finding the seek bar.
for name in ["Operation Briefing", "Editor's cut", "Part A", "Part C", ""] {
assert_eq!(Claim::of(name), Claim::Content, "{name}");
}
}
/// The names that cannot be settled by reading them.
#[test]
fn an_ambiguous_name_is_not_decided_by_itself() {
assert_eq!(Claim::of("Intro"), Claim::MaybeOpening);
assert_eq!(Claim::of("Credits"), Claim::MaybeEnding);
assert_eq!(Claim::of("Cast"), Claim::MaybeEnding);
// And a chapter read in isolation never resolves to a skip.
assert_eq!(chapter(0, "Intro", 0.0).role(), ChapterRole::Content);
}
/// **The first episode of Oshi no Ko.** No opening, no ending — an eighty
/// minute episode that begins with a long `Intro` that is the story, and
/// ends with a cast scroll. Offering "skip opening" over the first ninety
/// seconds of *that* skips the beginning of the series.
#[test]
fn a_long_intro_is_the_episode_and_not_an_opening() {
let chapters = vec![
chapter(0, "Intro", 0.0),
chapter(1, "Part A", 700.0),
chapter(2, "Part B", 2400.0),
chapter(3, "Cast", 4680.0),
];
let roles = roles(&chapters, Some(4800.0));
assert_eq!(
roles[0],
ChapterRole::Content,
"eleven minutes is not a theme"
);
assert_eq!(
roles[3],
ChapterRole::Ending,
"a cast roll at the end is one"
);
// Only the cast scroll is the tail; the episode is not skipped into.
assert_eq!(credits_start(&chapters, &roles), Some(4680.0));
}
/// The same name in the release that uses it for the theme song: ninety
/// seconds, near the front, and no other opening in the file.
#[test]
fn a_theme_length_intro_near_the_front_is_an_opening() {
let chapters = vec![
chapter(0, "Avant", 0.0),
chapter(1, "Intro", 90.0),
chapter(2, "Part A", 180.0),
];
assert_eq!(roles(&chapters, Some(1440.0))[1], ChapterRole::Opening);
// …but not when the file already says which chapter the opening is.
let chapters = vec![
chapter(0, "Intro", 0.0),
chapter(1, "OP", 90.0),
chapter(2, "Part A", 180.0),
];
let roles = roles(&chapters, Some(1440.0));
assert_eq!(roles[0], ChapterRole::Content);
assert_eq!(roles[1], ChapterRole::Opening);
}
/// The ordinary shape: the credits and the trailer for next week are one
/// run at the end, and the countdown starts where the credits do.
#[test]
fn the_tail_is_the_whole_run_of_credits_and_preview() {
let chapters = vec![
chapter(0, "OP", 0.0),
chapter(1, "Part A", 90.0),
chapter(2, "Part B", 700.0),
chapter(3, "ED", 1320.0),
chapter(4, "Preview", 1410.0),
];
let roles = roles(&chapters, Some(1440.0));
assert_eq!(credits_start(&chapters, &roles), Some(1320.0));
}
/// **Episode 46 of Fullmetal Alchemist Brotherhood.** A Part C *after* the
/// ending, and the preview after that. Only the preview may be skipped —
/// treating everything from the ending on as tail would drop a scene.
#[test]
fn content_after_the_ending_keeps_the_ending_out_of_the_tail() {
let chapters = vec![
chapter(0, "Part A", 0.0),
chapter(1, "Part B", 700.0),
chapter(2, "ED", 1260.0),
chapter(3, "Part C", 1350.0),
chapter(4, "Preview", 1400.0),
];
let roles = roles(&chapters, Some(1440.0));
assert_eq!(roles[2], ChapterRole::Ending, "it is still an ending");
assert_eq!(credits_start(&chapters, &roles), Some(1400.0));
}
/// A file that ends on the story has no tail at all, and one with no
/// chapters has nothing to read.
#[test]
fn a_file_that_ends_on_content_has_no_tail() {
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "Part A", 90.0)];
let roles = roles(&chapters, Some(1440.0));
assert_eq!(credits_start(&chapters, &roles), None);
assert_eq!(credits_start(&[], &[]), None);
// Everything skippable is a mis-tagged file, not an episode that is
// entirely credits: auto-advancing out of it at second zero would be
// worse than doing nothing.
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "ED", 90.0)];
let roles = vec![ChapterRole::Ending, ChapterRole::Ending];
assert_eq!(credits_start(&chapters, &roles), None);
}
#[test]
fn a_position_lands_in_the_chapter_that_contains_it() {
let chapters = vec![
chapter(0, "Intro", 0.0),
chapter(1, "OP", 24.0),
chapter(2, "Part A", 114.0),
];
assert_eq!(chapter_at(&chapters, 0.0), Some(0));
assert_eq!(chapter_at(&chapters, 23.9), Some(0));
// Exactly on a boundary belongs to the chapter that starts there,
// which is what makes "skip" land *out* of the opening rather than at
// its last frame.
assert_eq!(chapter_at(&chapters, 24.0), Some(1));
assert_eq!(chapter_at(&chapters, 3000.0), Some(2));
}
#[test]
fn a_chapter_before_the_first_one_starts_is_in_no_chapter() {
let chapters = vec![chapter(0, "OP", 24.0)];
assert_eq!(chapter_at(&chapters, 0.0), None);
}
#[test]
fn the_last_chapter_ends_where_the_file_does() {
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "Part A", 90.0)];
assert_eq!(chapter_end(&chapters, 0, Some(1440.0)), Some(90.0));
assert_eq!(chapter_end(&chapters, 1, Some(1440.0)), Some(1440.0));
// No duration yet and nothing after it: there is nowhere to skip to,
// and a button that seeks to zero would be worse than no button.
assert_eq!(chapter_end(&chapters, 1, None), None);
}
}
+4
View File
@@ -165,6 +165,10 @@ impl StreamSource {
uid: uid.clone(), uid: uid.clone(),
}; };
if let Some(stream) = self.inner.lock().pop(&key) { if let Some(stream) = self.inner.lock().pop(&key) {
// Before the ring is cleared, not after: a prefetch still in flight
// would otherwise re-insert into the ring this just emptied, and
// keep downloading for a stream nobody is reading.
stream.cancel_read_ahead();
self.inner.ring.forget(uid, stream.revision_id()); self.inner.ring.forget(uid, stream.revision_id());
} }
} }
+71 -16
View File
@@ -259,20 +259,12 @@ impl VideoStream {
/// wants an aborted block simply refetches it — the single-flight entry is /// wants an aborted block simply refetches it — the single-flight entry is
/// released by its guard when the task is dropped, so nothing waits on a /// released by its guard when the task is dropped, so nothing waits on a
/// call that will never finish. /// call that will never finish.
fn cancel_read_ahead(&self) { ///
let tasks = std::mem::take(&mut *self.inner.tasks()); /// Public because a seek is not the only reason to stop speculating: a
let mut cancelled = 0_u64; /// stream nobody is reading any more should not keep spending a phone's
for task in tasks { /// data plan, which is what [`crate::StreamSource::close`] uses this for.
if !task.is_finished() { pub fn cancel_read_ahead(&self) {
task.abort(); self.inner.cancel_read_ahead();
cancelled += 1;
}
}
if cancelled > 0 {
self.inner
.readahead_cancelled
.fetch_add(cancelled, Ordering::Relaxed);
}
} }
/// Queue the next few blocks after `from`. /// Queue the next few blocks after `from`.
@@ -300,10 +292,15 @@ impl VideoStream {
}; };
inner.readahead_started.fetch_add(1, Ordering::Relaxed); inner.readahead_started.fetch_add(1, Ordering::Relaxed);
let spawned = Arc::clone(inner); // Weak, so that a queue of prefetches cannot keep the stream they
// are speculating for alive — see `impl Drop for Inner`.
let spawned = Arc::downgrade(inner);
let task = tokio::spawn(async move { let task = tokio::spawn(async move {
let _slot = slot; let _slot = slot;
if let Err(e) = block_at(spawned, index).await { let Some(inner) = spawned.upgrade() else {
return;
};
if let Err(e) = block_at(inner, index).await {
tracing::debug!(index, error = %e, "read-ahead block failed"); tracing::debug!(index, error = %e, "read-ahead block failed");
} }
}); });
@@ -352,6 +349,39 @@ impl Inner {
.lock() .lock()
.unwrap_or_else(PoisonError::into_inner) .unwrap_or_else(PoisonError::into_inner)
} }
fn cancel_read_ahead(&self) {
let tasks = std::mem::take(&mut *self.tasks());
let mut cancelled = 0_u64;
for task in tasks {
if !task.is_finished() {
task.abort();
cancelled += 1;
}
}
if cancelled > 0 {
self.readahead_cancelled
.fetch_add(cancelled, Ordering::Relaxed);
}
}
}
/// A stream nobody holds any more stops speculating.
///
/// Read-ahead tasks hold a [`Weak`] rather than an [`Arc`], so their existence
/// does not keep this alive and this can run while they are still in flight —
/// which is the point. Without it, closing the player mid-episode leaves up to a
/// full read-ahead window downloading for a stream nobody is watching, and those
/// blocks then evict the ones the *next* stream has already fetched.
///
/// A prefetch that has already upgraded its handle keeps this alive for the one
/// block it is fetching. That block is the residue; the window behind it is not.
impl Drop for Inner {
fn drop(&mut self) {
for task in std::mem::take(&mut *self.tasks()) {
task.abort();
}
}
} }
/// Resolve one block: memory, then disk, then the network. /// Resolve one block: memory, then disk, then the network.
@@ -791,6 +821,31 @@ mod tests {
); );
} }
/// Closing the player mid-episode must stop the speculation too. On a phone
/// on cellular this is up to a full read-ahead window downloaded for a
/// stream nobody is watching — and those blocks then evict the ones the next
/// stream has already fetched.
#[tokio::test]
async fn abandoning_a_stream_stops_its_read_ahead() {
let fake = Fake::slow(&[1024 * 1024; 200], std::time::Duration::from_millis(200));
let stream = stream_over(Arc::clone(&fake), 4);
stream.read_range(0, 1_000).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
assert!(stream.stats().readahead_blocks > 0, "nothing to cancel");
let started = fake.fetches();
drop(stream);
// Long enough that every prefetch in flight would have completed.
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
assert!(
fake.fetches() <= started + 1,
"read-ahead outlived the stream: {} fetches after {started}",
fake.fetches(),
);
}
/// And the mirror image: steady playback must not keep throwing its own /// And the mirror image: steady playback must not keep throwing its own
/// read-ahead away. /// read-ahead away.
#[tokio::test] #[tokio::test]
+26 -3
View File
@@ -43,9 +43,32 @@ adb install -r android/app/build/outputs/apk/debug/app-debug.apk
``` ```
Before submitting Android work, run the Rust workspace gate from the repository Before submitting Android work, run the Rust workspace gate from the repository
root and `bash scripts/build-android.sh check`. Test phone and tablet layouts, root, then:
rotation, process recreation, playback/background controls, Picture-in-Picture,
download cancellation/resume, and an offline launch with networking disabled. ```bash
bash scripts/build-android.sh check # ktlint, Android lint, host tests
bash scripts/android-acceptance.sh --release # the on-device matrix, minified APK
```
`check` needs no device. Run the matrix with `--release` where you can: it
installs the signed minified APK and runs instrumentation against it, which is
the only path that exercises R8 — the step that has broken this app five distinct
ways that a debug build cannot reproduce. Without the `ANDROID_RELEASE_*`
variables below, drop `--release` and the debug APK is used instead. The matrix — phone and tablet layouts, rotation,
process recreation, playback and background controls, Picture-in-Picture,
download cancellation/resume, and an offline launch with networking disabled —
is `scripts/android-acceptance.sh`; `--list` prints every case and what it
needs, and `PSTR_ACCEPTANCE_ONLY=name` runs one. Five cases need a real share,
given as `PSTR_ACCEPTANCE_SHARE_URL` and `PSTR_ACCEPTANCE_SHARE_PASSWORD`; they
reach the device as instrumentation arguments and are stored nowhere. Without
them those cases report `skip`. `picture-in-picture` is still `pending`, so the
run tells you the matrix is incomplete — but no longer because of B40, which is
fixed; the case now just needs writing. `docs/TESTING.md` has the layer-by-layer
detail, including why lint runs against a baseline.
Requires JDK 17. `build-android.sh` locates it, because a newer JVM does not
fail with a version message — the Kotlin compiler throws
`IllegalArgumentException: 26.0.2` from an IntelliJ version parser.
## Release Signing ## Release Signing
+875 -2
View File
@@ -7,11 +7,884 @@ verified. Reference entries from PRs.
## Open ## Open
_None yet — the app is young enough that everything known is in All of B14–B33 came out of a static review of the Android client
`docs/DEVELOPMENT.md` as unbuilt rather than broken._ (`android/` and `crates/pstr-android`) on 2026-08-07. **None was reproduced on a
device** — there was no Android acceptance harness to reproduce them with, which
is itself B42. Each states the reasoning that identifies it; treat the
reproduction step as part of the fix. The remediation order is `plan.md` at the
repository root; Phases 1 to 5 of it have landed except for the tail of B41 and
one row of Phase 5's table — everything from B14 to B39 is below in **Fixed**,
and B41 states which of its items are done.
### B41 — assorted correctness and hygiene defects
Individually small, grouped so they are not lost. The items marked **Landed**
came with Phases 4 and 5; the rest are still open, which is why this entry is.
What remains is one item — the asynchronous track-list read-back — plus the
`SettingsStore` construction on the main thread.
- `NativeMpvHost.kt:131-135` reads the track list back immediately after an
asynchronous property set, so the selection tick lags by up to a second;
`alang`/`slang` are applied only at load (`pstr_mpv.cpp:225-227`), so a
language change takes effect one episode late.
- **Landed.** `NativeMpvHost.kt` polled on `Dispatchers.Main.immediate`, and
`tracks_json` issues five `mpv_get_property` calls per track — 51 synchronous
property reads on the UI thread for a 10-track file, contending on the core
lock the demuxer holds during a fetch. The poll loop now runs on
`Dispatchers.Default` and hops to the main thread only to invoke the state
listener.
- **Landed.** `PlaybackService` constructed `NativeMpvHost` in `onCreate` with
a `check(it != 0L)` in the constructor, so a libmpv init failure crashed the
process and left the graceful "this build does not include libmpv" path
unreachable. The constructor is private behind `NativeMpvHost.createOrNull()`,
the service holds a nullable host, and the binder hands back a nullable one —
which is what `PlayerScreen` was already written for.
- `PlaybackService.kt:43` constructs `SettingsStore` on the main thread inside a
hot callback; the first SharedPreferences access is disk I/O.
- **Landed.** `MainActivity` set `bound` only in `onServiceConnected`, so an
activity destroyed with a bind still pending never unbound. It is now set from
the `bindService` return value.
- **Landed.** The title screen held the only `BackHandler`, so on Shares,
Downloads and Settings the system back button exited the app. Back from a
secondary tab now returns to the library.
- **Landed.** `formatBytes` had no KiB branch — a 512 KiB partial rendered as
`524288 bytes`.
- **Landed.** `ui/theme/Theme.kt` overrode ten roles of `darkColorScheme()` and
left `surfaceContainer*` (what Card and NavigationSuite draw with) at the
Material baseline, with no light scheme at all. The scheme is now built from
the palette `pstr_core::appearance` resolves — the same flavours, accent
pairings and contrast rule the desktop client uses — so both `surfaceContainer*`
and a light flavour are covered. Dynamic colour is deliberately still not
used: the app has a palette of its own and a picker for it, and taking the
wallpaper's hues instead would override the choice the viewer just made.
- **Landed.** `native_handle()` minted a fresh id per call, so two calls on one
stream leaked a registry entry permanently. It now remembers the id it
published under and returns it again.
- **Landed.** `directory_bytes` recursed with no depth limit, so a symlink
cycle in the cache was an uncatchable stack overflow reached from the settings
screen. It stops at 16 levels.
- **Landed.** `reject_null_surface` leaked the `jclass` from `FindClass`; it is
released. `nativeTracks`/`nativeChapters` were declared non-null on the Kotlin
side, so a null from `NewStringUTF` became an NPE swallowed by `runCatching`
into a silently empty track list; both are nullable now. `escape()` passed
4-byte UTF-8 to `NewStringUTF`, which takes Modified UTF-8 — an emoji in a
track title was mojibake and aborted under CheckJNI. It now decodes the input
and re-encodes astral characters as surrogate pairs, dropping anything
malformed rather than passing it on.
- **Landed.** `SettingsStore.kt` duplicated `pstr_core::prefs::PlaybackPrefs`
(`autoplay` most visibly), which `docs/ANDROID.md:7` says not to do. Playback
preferences are now read and written over the bridge, and `SettingsStore` holds
only what is Android's alone — the Wi-Fi-only and background-audio settings.
`autoSkip` went the other way, into `PlaybackPrefs`, where the desktop client
can pick it up.
- **Landed.** `ProtonStreamApp.kt` carried five unused imports, which was
evidence that no lint gate ran. ktlint runs in `check`.
### B42 — nothing verifies any of the above on a device
**Symptom.** Every entry from B14 to B41 was found by reading, because there was
no way to run it.
**Cause.** `android/app/src/test/` held one file, `DownloadCoordinatorTest.kt`,
whose five tests cover pure helpers. There was no `src/androidTest/` directory at
all, so nothing exercised the Keystore, WorkManager, or the UniFFI boundary on
hardware. `scripts/build-android.sh` mapped `check` to `lintDebug
testDebugUnitTest`, and neither `build.gradle.kts` had a `lint { }` block, a
baseline, or ktlint/detekt. `docs/ANDROID.md` mandates a manual matrix — tablet
layout, rotation, process recreation, PiP, download cancel/resume, offline launch
— and `.github/workflows/release.yml:63,164` blocks Android publication until an
"on-device playback matrix" passes. Neither existed as a script; compare
`proton-drive-linux/scripts/fuse-acceptance.sh`. `docs/TESTING.md` did not
contain the word Android.
**Fix, partly landed.** `scripts/android-acceptance.sh` now drives the
device-side matrix, `src/androidTest/` covers the Keystore, the UniFFI boundary
and WorkManager, host tests run under Robolectric, and `check` runs ktlint and a
lint gate with `warningsAsErrors`. Enabling that gate immediately paid for
itself: it reported the four `commit()` calls of
[B20](#b20--queueing-a-season-blocked-the-ui-thread) as `ApplySharedPref` and
the PiP omission of [B40](#b40--picture-in-picture-was-entered-but-never-adapted-to)
as `PictureInPictureIssue`, without being told to look for either. Both have
since landed and both left `lint-baseline.xml`, which is down from 34 entries to
25 and shrinks as the rest land.
**The live cases now exist.** `LiveShareTest` takes the share link as an
instrumentation argument — never a constant, never a file — and covers
`catalog`, `stream-open`, `playback`, `download-cancel-resume` and `watch-state`.
`playback` is the load-bearing one: it drives Proton block storage, decryption,
the Rust stream C ABI, the JNI adapter and libmpv's demuxer, and asserts the
position advances, which can only happen if real decrypted bytes reached the
decoder. One live run serves all five cases, because each re-crawl of a real
share costs about six minutes and the crawl is not what any of them tests.
**Still open, and the reason this entry stays open.** `picture-in-picture` is
`pending`. It is no longer blocked on the app —
[B40](#b40--picture-in-picture-was-entered-but-never-adapted-to) landed, so
there is correct behaviour to assert — but the live suite drives the engine and
libmpv directly, and PiP is a property of the window, so the case needs an
activity-level driver that does not exist yet. Every run still prints that the
matrix is incomplete. `process-recreation` has been promoted off its `xfail`
now that [B35](#b35--navigation-and-player-state-did-not-survive-process-recreation)
has landed, and has not been re-run on hardware since.
**Verified on hardware**, 2026-08-08, Pixel 6 (`arm64-v8a`, API 37) against the
signed minified release APK:
```
passed=13 failed=0 xfailed=1 xpassed=0 skipped=0 pending=1
```
against a real share: every offline case passes, `process-recreation` fails as
the `xfail` for [B35](#b35--navigation-and-player-state-did-not-survive-process-recreation)
predicts, the five live cases pass, and `picture-in-picture` is `pending`. All 20
offline instrumentation tests pass on the release build. `bash scripts/build-android.sh check` passes
with 15 host tests, no new lint findings and no compiler warnings.
The first hardware run also found four defects in the harness itself and five in
the release build; they are [B43](#b43--the-acceptance-harness-could-not-run-against-the-shipping-build)
and [B44](#b44--r8-broke-the-app-under-instrumentation-in-five-places).
## Fixed ## Fixed
### B36 — an invalidated connection stayed live, and releasing a stream could no-op
**Symptom.** Removing a share left its authenticated visitor client usable for
the life of the process; adding a share made the next episode start pay a cold
handshake for every configured share.
**Cause.** `invalidate_connection` only bumped an `AtomicU64`. The cached
`Connection` — including the live `ProtonDrivePublicLinkClient` for the removed
share — stayed in `self.connection` until something next called `connection()`.
Because the counter was process-wide rather than per-share, the next
`connection()` re-handshaked all of them. The same counter made `release_stream`
silently skip `source.close` whenever a share was mutated while the player was
open, and it returned `()`, so Kotlin could not tell.
**Fix.** `invalidate_connection` now takes the share id that changed, drops the
cached connection there and then, and parks every *other* share's client for the
next build — `SharedLibrary::open_all_reusing` takes them, so adding one link
re-handshakes one link. The removed share's client is the one client not carried
over, which is what makes it unreachable rather than merely stale. The cache
slot moved to a sync mutex, with the open handshakes serialised behind a
separate `connecting` lock, because the mutations that invalidate it are sync
`&self` methods called from the JNI thread. `release_stream` closes against
whatever connection is cached, regardless of generation, and returns
`Result<(), BridgeError>`.
**Verified.** `cargo test --workspace`, `cargo clippy --workspace --all-targets`.
Not exercised on a device: the reuse path needs two configured shares and a
network.
### B37 — one provider error abandoned the rest of the library, and episode metadata was never fetched
**Symptom.** Matching reported a failure and left most of the library
unenriched; Android never showed provider episode titles at all.
**Cause.** `match_titles` ran serially and propagated the first error with `?`,
where the desktop equivalent fans out under a semaphore and deliberately counts
failures rather than aborting. The bridge also had no analogue of desktop's
`Work::Episodes`/`episodes_of`, so `Catalog::set_episode_metadata` was never
populated and `all_episode_metadata` was dead from Android's side.
**Fix.** `match_titles_inner` is desktop's `run_match` on a `JoinSet` under a
two-permit semaphore: the same two kinds of work (search, or episodes for an
already-matched title), the same "misses are stored, failures are not" rule, and
a `MatchSummary` of matched/unmatched/failed/episodes returned across the bridge
and shown to the viewer. `episodes_of` moved out of `pstr-app` into
`MetadataService::title_episodes` so both clients call one implementation rather
than two copies. `library()` now reads `all_episode_metadata` and
`EpisodeRecord` carries the provider's name, overview, still and air date; the
episode row shows the provider's name where there is one and the parsed filename
detail otherwise.
**Verified.** `cargo test --workspace` (desktop's matching tests cover
`title_episodes` unchanged), `bash scripts/build-android.sh check`. The fan-out
and the failure counting are not covered by an offline test — both need a
provider.
### B38 — the library was rebuilt in full on every keystroke, and the downloads screen composed eagerly
**Symptom.** Search was sluggish on a large library; the Downloads tab froze on
first frame with many offline episodes.
**Cause.** `library()` ran four table scans, a `std::fs::metadata` per offline
file, a `Library::build` and a full record conversion before the search argument
was applied as a filter, and `AppViewModel` called it per keystroke. It also
*wrote* — `remove_offline_file` — from what read as a query. On the Kotlin side
the Downloads screen was a `Column(verticalScroll)` with `forEach`, not a
`LazyColumn`.
**Fix.** The whole conversion is cached behind `Catalog::writes()`, which is
SQLite's own `total_changes` — a counter no write path has to remember to
invalidate, which is the failure mode a hand-maintained one has. A search is now
a filter over records that already exist. The pruning of offline rows whose
bytes are gone moved into `prune_offline_files()`, called once per full reload
rather than per keystroke, which is also what makes `library()` cacheable at
all. Downloads is a `LazyColumn` keyed by share and link id.
**Verified.** `cargo test --workspace`, `bash scripts/build-android.sh check`.
The improvement itself is a timing claim and has not been measured on a device.
### B39 — a release build logged at trace, and a TLS init failure was discarded
**Symptom.** Shipped APKs wrote rustls handshake internals and everything else
in the dependency graph to logcat. A platform-verifier initialisation failure
surfaced much later as an unexplained certificate error.
**Cause.** `initTls` called `android_logger::init_once` with `LevelFilter::Trace`
and no `cfg!(debug_assertions)` guard, discarded the result of
`rustls_platform_verifier::android::init_with_env`, and returned `void`, so
Kotlin could not detect it either.
**Fix.** Debug in a debug build, Info in a release one. `initTls` returns a
boolean, logs the verifier error at `error` level, and `NativeRuntime.tlsReady`
carries the answer to the ViewModel, which tells the viewer that requests will
fail instead of leaving them to discover it per share.
**Verified.** `bash scripts/build-android.sh check`. The failure branch cannot
be provoked without a broken platform verifier.
### B24 — playback was structurally impossible without a video surface
**Symptom.** A load issued while the surface was destroyed — background audio,
or the screen off — stalled five seconds and then failed with `libmpv rejected
the stream or no video surface was available`.
**Cause.** `Player::load` waited on `render_cv_` for a non-null `render_` with a
5 s timeout, and `render_` was only ever created from the surface-attach path.
There was no fallback. So the one setting whose entire purpose is playing
without a visible window could not work, by construction.
**Fix.** EGL and the mpv render context are created with the render thread and
need no window at all — a 1×1 pbuffer is enough to make a context current, and
`vo_libmpv` only needs *some* consumer. Attaching a surface then adds a window
surface to a context that already exists, and `load` waits for nothing.
**Verified.** Compiles for both ABIs. Needs the background-audio acceptance case.
Not changed, and deliberately: `withOpenHandle` still holds its lock across the
whole JNI call. That lock is what serialises `nativeDestroy` against every other
entry point, and with the five-second wait gone there is no longer a JNI call
that blocks long enough for holding it to matter — `nativeDestroy` itself now
runs on its own thread (B16).
### B25 — frames were not drained while the surface was detached
**Symptom.** After the screen went off, or after PiP tore down, video came back
desynchronised from audio rather than resuming cleanly.
**Cause.** `render_update` kept setting `frame_ready_`, but
`mpv_render_context_update`/`_render` were only called when a window surface
existed. `vo_libmpv` expects its host to keep draining; with no consumer the
video thread stalls.
**Fix.** With no window the render loop makes the pbuffer current and renders
with `MPV_RENDER_PARAM_SKIP_RENDERING`, which consumes the frame and drops it.
The frame is accounted for either way, which is all mpv asks.
**Verified.** Compiles for both ABIs. Needs a device to observe the sync.
### B26 — the seek bar issued a seek per drag pixel
**Symptom.** Dragging the seek bar on a network stream was far slower to settle
than a single tap-to-seek, and the thumb visibly snapped backwards under the
finger.
**Cause.** The `Slider` had no drag-local state and no `onValueChangeFinished`,
so every touch-move called `nativeSeek`. Each seek aborts every outstanding
prefetch, so a one-second drag issued dozens of seeks and threw away every
in-flight block — and the thumb was drawn from `state.position`, which only
updates four times a second.
**Fix.** The drag position is held locally and drives both the thumb and the
elapsed-time label; one seek is issued on release.
**Verified.** Compiles and passes `check`.
### B27 — a network-backed stream gave the viewer no feedback
**Symptom.** When the stream stalled the picture simply froze. When playback
failed after `loadfile`, nothing was shown at all.
**Cause.** Nothing observed `paused-for-cache`/`cache-buffering-state` and
nothing handled `MPV_EVENT_SEEK`/`MPV_EVENT_PLAYBACK_RESTART`, so there was no
buffering state to render — desktop has had one all along
(`pstr-app/src/ui/transport.rs`). The error slot caught setup exceptions only:
no `MPV_EVENT_LOG_MESSAGE` and no end reason reached the UI, and the message
could not be dismissed.
**Fix.** Both cache properties are observed and both seek events handled, giving
`MpvPlaybackState.stalled` — the picture is stopped for a reason the viewer did
not choose — which draws a spinner and, while buffering, the cache percentage.
mpv's own error log is requested at `error` level and the *first* line about the
open file is kept: a failure cascades, and the first line is the one that names
the cause. It is consumed by reading, cleared by the next load, and reaches the
same error slot, which is now dismissible — a subtitle track that failed to load
leaves an episode that plays perfectly well behind what used to be a permanent
message.
**Verified.** Compiles for both ABIs and passes `check`. The stall path needs a
throttled link on a device.
### B33 — audio focus was handled halfway, and unplugging headphones played out loud
**Symptom.** After any interruption playback stayed paused until the user
manually resumed. Unplugging headphones continued playback on the speaker.
**Cause.** `AUDIOFOCUS_LOSS_TRANSIENT` paused and `AUDIOFOCUS_GAIN` was
explicitly `Unit`, so nothing ever resumed; `AUDIOFOCUS_LOSS` did not abandon
the request; the `requestAudioFocus` result was discarded, so playback proceeded
even on `AUDIOFOCUS_REQUEST_FAILED`. There was no `ACTION_AUDIO_BECOMING_NOISY`
receiver anywhere in the module.
**Fix.** The four cases are treated as the four different things they are: a
transient loss pauses and remembers that *it* did — so a viewer who had already
paused is not resumed for them — and gain resumes only in that case; a duckable
loss drops the volume and restores the remembered one rather than a re-read
ducked one; a permanent loss abandons the request instead of holding it; and a
refused request pauses rather than playing over whatever holds focus. A
non-exported `becomingNoisy` receiver pauses on the jack being pulled.
**Verified.** Compiles and passes `check`. Every case needs a device.
### B34 — a partial EGL failure was permanent, silent and undiagnosable
**Symptom.** A black screen that persisted for the life of the process.
**Cause.** `initialize_egl` assigned `display` before it could still fail at
`eglChooseConfig`, `eglCreateContext` or `eglCreatePbufferSurface`. The retry
guard on the next attach was `display == EGL_NO_DISPLAY`, which was now false,
so initialisation was never retried — and every later attach used an
uninitialised `config` and `EGL_NO_CONTEXT`. No `eglGetError` was checked or
logged anywhere in the file.
**Fix.** Initialisation is all-or-nothing: any failing step unwinds everything
before it and restores the sentinels, so the guard stays honest, and logs
`eglGetError` with the name of the step that failed. Landed early, with B24 —
the two are the same function and B24's restructure depends on "is there a
display" meaning what it says.
**Verified.** Compiles for both ABIs. The failure path needs a device that
actually fails.
### B35 — navigation and player state did not survive process recreation
**Symptom.** Toggling dark mode, changing font size, or returning after the
process was killed dropped the viewer back on the Library tab with playback
gone. Closing the player reset the title page's scroll and expanded seasons.
**Cause.** The destination, the selected title, the play request and the
player's episode index were plain `remember`, and nothing was written to
`onSaveInstanceState`. The `configChanges` list covered orientation and screen
size but not `uiMode|density|fontScale|locale|layoutDirection`. And when a play
request existed the composable *returned* before the scaffold, disposing the
whole subtree, so the title page was rebuilt from the top on exit.
**Fix.** What is saved is keys, not records: which title and which episode. A
`TitleRecord` is neither parcelable nor still current after a library reload, so
everything else is derived from `state.titles` — which also means a reload that
renames a season updates the open screen instead of pinning a stale copy. The
player's index is hoisted to the same place for the same reason.
`configChanges` covers the five missing configurations, and the player is drawn
*over* the scaffold rather than instead of it, so leaving it finds the title page
where it was.
**Verified.** Compiles and passes `check`. The `process-recreation` acceptance
case is `xfail` against this bug — it should now be promoted, which needs a
device run to confirm.
### B16 — tearing down the player could block the main thread for a whole block fetch
**Symptom.** Closing the player or stopping the service on a degraded connection
froze the UI; a seek issued during a stalled read did not take effect until the
read completed.
**Cause.** `~Player` calls `mpv_terminate_destroy`, which joins mpv's demuxer
thread — and that thread may be parked inside `stream_read` →
`read_range_for_native` → `runtime.block_on(stream.read_range(...))` on a 4 MiB
Proton block. `stream_cancel` only set an atomic that `stream_read` checked at
*entry*, so an in-flight read was not interruptible and mpv's `cancel_fn` never
reached Rust at all. `pstr-stream` has had a working cancellation story since
[B6](#b6--read-ahead-starved-the-seeks-it-was-supposed-to-smooth); the Android
path could not reach any of it. And `close()` ran the whole teardown on the main
thread, from `PlaybackService.onDestroy`.
**Fix.** `pstr_android_stream_cancel` is a new C ABI entry point that trips a
`tokio::sync::Notify` the in-flight `read_range` is selected against, and
`stream_cancel` calls it. Only a waiting read is affected — a cancel with no
reader is dropped — which is what makes it safe to call speculatively.
`nativeDestroy` moved to a named teardown thread; `closed` is set first so
nothing new enters the handle, and the lock is held only to let a call already
inside finish.
**Verified.** Compiles for both ABIs. The latency claim needs the throttled-link
acceptance case.
### B20 — queueing a season blocked the UI thread
**Symptom.** "Download show" on a long series froze the app, up to an ANR.
**Cause.** `DownloadStateStore` used blocking `.commit()` and
`DownloadCoordinator` looped it once per episode, from UI click handlers. A
200-episode series was 200 synchronous fsyncs plus 200 `enqueueUniqueWork` calls
on the main thread.
**Fix.** Every write is `apply()` — the in-memory map is updated before it
returns, so a read that follows a write in the same process still sees it — and
a queue is one `putAll` rather than one write per episode. Bulk enqueue runs on
the coordinator's own IO scope.
**Verified.** `check` passes, and the four `ApplySharedPref` entries left the
lint baseline.
### B21 — cancelling a coroutine did not cancel the Rust work behind it
**Symptom.** Backing out of a screen left provider requests and SQLite writes
running; a cancelled download kept downloading and kept calling back into a
`DownloadObserver` WorkManager considered dead.
**Cause.** Every async export had the shape `runtime.spawn(async { … }).await`.
UniFFI cancellation drops the Rust future, and dropping a `JoinHandle`
*detaches* the task rather than aborting it.
**Fix.** All eight exports go through `spawned(&runtime, …)`, which wraps the
handle in an `AbortOnDrop` that aborts on the way out. Abort points are await
points, so a blocking SQLite statement always completes; what is abandoned is
the work after it.
**Verified.** Compiles and passes clippy; the observable half needs the
cancelled-download acceptance case.
### B22 — playback and downloads never refreshed an expired visitor session
**Symptom.** After the app sat in the background for hours, playing an episode
failed with an authentication error, and the only way out was a pull-to-refresh.
**Cause.** `SharedLibrary::refresh_session` was called only from `crawl_inner`.
`open_stream_inner` and `download_episode_inner` went straight to
`connection().source.open(...)` with no refresh and no retry, and the generation
counter invalidates on share-store mutations only — never on session age or an
auth failure.
**Fix.** Both open paths go through `open_from_source`, which has two guards
because there are two ways to lose a session. Age is the common one, caught
before the request by a per-share timestamp with a five-minute window; anything
else only shows up as a failure, so one retry behind a refresh covers it. A
refresh that itself fails is not reported — the open's own error is more useful.
**Verified.** Compiles. Needs a live session to actually expire.
### B23 — read-ahead outlived the stream it was reading for
**Symptom.** Closing the player mid-episode kept consuming data, and evicted
blocks the *next* stream had already fetched.
**Cause.** There was no `impl Drop` anywhere in `pstr-stream`.
`VideoStream::read_ahead_from` spawned detached tasks each holding an
`Arc<Inner>`, and `cancel_read_ahead` ran only on a detected seek.
`StreamSource::close` popped the LRU entry and called `ring.forget`, but the
in-flight prefetches kept running and re-inserted into the ring the `forget` had
just cleared.
**Fix.** Prefetch tasks hold a `Weak<Inner>` and upgrade only for the block they
are fetching, so a queue of speculation no longer keeps its own stream alive.
`Drop for Inner` then aborts whatever is left, and `StreamSource::close` cancels
explicitly *before* clearing the ring, so nothing can re-insert into it. The
residue is the one block a prefetch had already upgraded for; the window behind
it is not.
**Verified.** `abandoning_a_stream_stops_its_read_ahead` in
`crates/pstr-stream/src/stream.rs` — drops a stream with prefetches in flight
against a slow source and asserts at most one more fetch lands.
### B29 — the Wi-Fi-only setting did not reach work already queued
**Symptom.** Queue a season with the toggle off, turn it on, leave the house —
the queue downloaded over cellular.
**Cause.** `Constraints` were baked in from `SettingsStore(context).wifiOnly` at
enqueue time, and nothing re-enqueued pending work when the setting changed.
**Fix.** `DownloadCoordinator.applyNetworkPolicy` re-issues every queued or
running download with the current constraint, and the toggle calls it. Running
work needs no special handling: WorkManager stops it once the new constraint
stops being met, and the `.part` file makes that a pause rather than a loss.
**Verified.** Compiles and passes `check`.
### B30 — a partial download could become unreclaimable
**Symptom.** Settings reported several GiB of unfinished downloads with no way
to delete them short of clearing app data.
**Cause.** `remove_all_offline` iterated `catalog.all_offline_files()` —
*completed* downloads only — so a `.part` with no catalog row was never touched.
Nothing checked free space before enqueuing, and the work constraints set
neither `setRequiresStorageNotLow` nor anything else, so ENOSPC was reached by
writing to it.
**Fix.** "Delete all offline" now sweeps the offline directory after walking the
catalog, because the catalog is not a complete account of what is on disk — the
orphaned `.part` is exactly the file the button exists to reclaim. Callers
cancel outstanding work first, so nothing swept is being written to. Enqueue
gained a `StatFs` precheck against the request's own total with 256 MiB of
headroom, refusing as a recorded failure rather than an ENOSPC, and the work
constraint gained `setRequiresStorageNotLow`.
**Verified.** Compiles and passes `check`. The reclaim path needs a device with
a real partial download.
### B31 — download progress wrote to disk a dozen times a second
**Symptom.** Notification rate-limiting warnings and visible jank during a fast
download; a pause could silently revert.
**Cause.** The observer ran per 4 MiB block and each invocation did a
`setProgressAsync` (a WorkManager DB write), a `setForegroundAsync`, a
`createNotificationChannel` re-run, and a `commit()`-backed read-modify-write of
the retained record. At 50 MB/s that is roughly twelve of each per second. The
read-modify-write was not atomic against `pause()`: a write landing between the
two halves put `RUNNING` back over `PAUSED`, after which the worker's own
cancellation handler saw a status that was not `PAUSED` and recorded
`CANCELLED`. The pause was lost.
**Fix.** A report is made only when the percentage moves or a second has passed,
and the final one is never suppressed. The channel is created once per run.
`DownloadStateStore.update` does the read and the write under one process-wide
lock, and `pause`/`cancel` go through it too — which also stops a screen's stale
copy from clobbering the progress the worker has since written.
**Verified.** Compiles and passes `check`, host tests included. The lost-pause
race needs the concurrent-pause acceptance case.
### B14 — a failed episode was marked watched and the next one autoplayed
**Symptom.** An episode that failed to demux was recorded as watched, the player
advanced to the next one, and the foreground service tore down. No error was
shown anywhere.
**Cause.** `pstr_mpv.cpp` set `state_.ended` on `MPV_EVENT_END_FILE` without
reading `mpv_event_end_file.reason`, so `EOF`, `STOP`, `ERROR` and `REDIRECT`
were indistinguishable. Three consumers read the flag as "played to the end":
the watch-state write, the autoplay advance, and the service's teardown. The
desktop player has always modelled this — `pstr-player/src/player.rs:46-70`
carries `EndReason::{Eof,Failed,Stopped}` — so it was an Android-only
divergence.
**Fix.** `EndReason` now exists on both sides of the JNI boundary with the same
five values, filled from `mpv_event_end_file.reason` and carried through
`MpvPlaybackState.endReason`. The watched write and the autoplay advance test
for `Eof` alone; `Failed` puts a message in the player's error slot instead of
silently skipping the episode.
**Verified.** Compiles for both shipped ABIs; the behaviour needs the `playback`
acceptance case against a file mpv cannot demux, which does not exist yet.
### B15 — an episode transition could release the incoming episode's own stream
**Symptom.** Changing episodes intermittently killed the new episode
immediately, or skipped it outright.
**Cause.** `loadfile` is asynchronous. `Player::load` reset the state and bumped
the generation counter, then mpv stopped the outgoing file and emitted
`END_FILE(STOP)` — which the event thread applied to the *already bumped*
generation. The 250 ms poller could sample inside that window and produce
`MpvPlaybackState(ended=true, media=<new key>)`, a reading that passed every
generation guard in the code. The service then released the stream handle
Kotlin had already swapped to the new episode's, so every subsequent
`stream_read` returned `-1`. Separately, `NativeMpvHost.play` released the
outgoing handle the instant `nativeLoad` returned, while the outgoing demuxer
might still be reading it.
**Fix.** The native state carries a `loading` flag, set when `loadfile` is
issued and cleared by mpv's own `START_FILE`. Everything mpv reports in that
window describes the outgoing file, and is now discarded rather than attributed
to the incoming one — the end reason above all, but also position, duration and
the video dimensions. (`pause`, `volume` and `mute` belong to the core, carry
across a load, and are still applied.) Stream release moved out of Kotlin
entirely and into `stream_close`, which is the one moment defined to be after
mpv's last read.
**Verified.** Compiles for both ABIs. Needs the rapid-episode-switch acceptance
case.
### B17 — autoplay into the background crashed the app
**Symptom.** With background audio on, an episode ending while the activity was
stopped could crash with `ForegroundServiceStartNotAllowedException`.
**Cause.** The service retired itself — `stopForeground` plus `stopSelf` — the
moment a file ended, and `onPlaybackStarted` then called `startService` +
`startForeground` for the next one. Composition survives `onStop`, so autoplay
runs with the app backgrounded, and by then the app held no foreground state at
all: the API 31+ background-start restriction applied to precisely the case the
`backgroundAudio` setting exists to serve.
**Fix.** Teardown is deferred rather than immediate. `NativeMpvHost.advancing`
is raised by the player before it opens the next episode's stream and lowered
when `play` returns either way; the service waits it out before retiring, with a
30 s ceiling so a failed transition still ends. `startForeground` is not
re-entered when the service is already foregrounded, and the start pair is
wrapped so the exception is a lost notification rather than a crash.
**Verified.** Compiles. Needs an acceptance case that ends an episode with the
activity stopped.
### B18 — a Keystore failure panicked across the FFI and left no way back
**Symptom.** If the Keystore key was invalidated — lockscreen change, device
restore, alias loss — every share became permanently unopenable, and the user
saw a Java stack trace in a snackbar.
**Cause.** `BridgeError` did not implement `From<UnexpectedUniFFICallbackError>`,
so uniffi's generic converter routed to `handle_callback_unexpected_error`,
which panics unconditionally — on whichever thread was calling, including a
tokio worker in the middle of a download. `KeystoreSecretStore` threw raw Java
exceptions throughout, none of them a `BridgeError`. `DownloadObserver`'s
methods were not `Result`-typed at all, so a `setForegroundAsync` throwing
(routine on API 31+ when backgrounded) panicked mid-download. And there was no
recovery path: only Remove-and-re-add, which the same error blocks, because
removal deletes a secret the store can no longer read.
**Fix.** Four parts. `BridgeError` implements the conversion, so an unmapped
Kotlin exception is an ordinary error. `KeystoreSecretStore` wraps every
operation and leaves only `BridgeException`s, carrying the message and not the
stack trace. `DownloadObserver`'s two methods are fallible: a progress report
that cannot be delivered is logged and ignored, and an unanswerable cancellation
question is answered "no", because the `.part` file makes the next attempt a
resume either way. For recovery, `ShareStore::replace_secrets` re-supplies a
share's link while keeping its id — so the catalog rows and offline files keyed
by it survive — reachable from a "Re-enter link" action on each share. It
refuses a link for a different token. `KeystoreSecretStore.set` also replaces a
permanently invalidated key rather than failing against it, since nothing that
key sealed was readable anyway.
**Verified.** `cargo test -p pstr-core shares` covers both halves of
`replace_secrets`; the instrumentation tests for tampered, truncated and
key-destroyed payloads now assert `BridgeException` specifically. The
end-to-end recovery flow needs a device.
Not a defect, recorded so it is not re-litigated: the IV handling is correct — a
fresh random IV per `Cipher.init(ENCRYPT_MODE)`, prepended, 128-bit tag. The key
is still not auth-bound and `setUnlockedDeviceRequired(true)` is still not set,
which remains a deliberate-looking choice that has never been written down.
### B19 — one oversized poster crashed the app on every launch
**Symptom.** The library grid OOM-crashed, and kept crashing after a restart
until app data was cleared.
**Cause.** `RemoteArtwork.kt` bounded the *compressed* download at 12 MiB and
then decoded with no `inJustDecodeBounds` pre-pass and no `inSampleSize`. A
12 MiB PNG can be 12000×12000, which is a 576 MB `ARGB_8888` allocation. The
file was written to the cache *before* the decode, and that cache was never
pruned and had no size cap, so the poison persisted. There was no in-memory
cache either: `remember(url)` is per-composition, so scrolling a
`LazyVerticalGrid` re-read and re-decoded at full resolution on every item
re-entry.
**Fix.** A bounds pass settles an `inSampleSize` against a 1024 px longest edge
before anything is allocated. Bytes are written to the disk cache only once they
have decoded, so a hostile image is a missing poster rather than a permanent
crash. The directory is pruned oldest-first to 48 MiB after each new entry, and
a byte-sized `LruCache` in front of it — capped at an eighth of the heap, 4–32
MiB — means a scroll costs neither a read nor a decode.
**Verified.** Compiles and passes `check`. The crash-loop case needs a device
and a deliberately large image.
### B28 — closing the player leaked the Rust stream every time
**Symptom.** Ring-buffer and disk-cache handles accumulated for the life of the
process, once per episode change.
**Cause.** The player disposed with `scope.launch { session.closeEngine() }`
where `scope` was a `rememberCoroutineScope()`. `launch` dispatches through
`AndroidUiDispatcher` rather than running inline, and by the time it would have
run the composition had left and that scope was cancelled — so
`releaseStream` never executed. The session is `remember(key)`, so this fired on
every episode change as well as every exit.
**Fix.** `RustStreamSession.close` runs the release on a process-lifetime scope
of the session file's own, matching the lifetime of the engine it releases to.
The one moment this has to run is the one moment a composition scope is
guaranteed to be cancelled, so the scope cannot be the caller's.
**Verified.** Compiles and passes `check`.
### B32 — the media notification and lock-screen control were unusable
**Symptom.** The collapsed media notification showed no buttons, no title and no
art; the notification was rewritten four times a second.
**Cause.** `PlaybackService`'s `onStateChanged` callback rebuilt both
`PlaybackState` and `MediaMetadata` on every poll tick, unconditionally. The
metadata carried only `METADATA_KEY_DURATION` — no title, artist or art — and
`notification()` hardcoded `"proton-stream"` / `"Playing"` rather than the
episode. `Notification.Action.Builder` was passed a `null` icon while
`setShowActionsInCompactView(0, 1)` asked MediaStyle to render icons, so the
buttons were invisible. Neither the state actions nor the notification carried
skip-next/previous, so there was no episode control from the lock screen or a
Bluetooth remote, and `setSessionActivity` was unset.
**Fix.** The player publishes a `NowPlaying` — episode, show, detail, poster URL
and whether the playlist has an episode either side — through `NativeMpvHost`,
because the composition that knows those things is unreachable from a service.
`PlaybackService` collects it, fetches the poster through the same HTTPS-only,
disk-cached loader the library grid uses (so the shade costs no second
download), and builds `MediaMetadata` with title, artist, description and art.
The notification gains framework icons, previous/next actions gated on the
playlist position, `setSessionActivity`, a colorized MediaStyle on the app's
accent, and a dedicated monochrome status-bar glyph. Re-posting is gated on a
`NotificationShape` — the inputs the notification is actually built from — so a
poll tick that changes only the position no longer rewrites it.
**Verified.** Compiles and packages; `check` clean. The rendered result needs a
device.
### B40 — Picture-in-Picture was entered but never adapted to
**Symptom.** The full controls overlay, the skip button and the up-next card
drew inside the PiP window, which itself had no play/pause control. On a device
using gesture navigation it was usually never entered at all.
**Cause.** There was no `onPictureInPictureModeChanged` override anywhere.
`MainActivity` set no `setActions`, hardcoded `Rational(16, 9)` rather than
deriving the video's aspect, and called `setAutoEnterEnabled(true)` on params
that were passed to `enterPictureInPictureMode` but never registered via
`setPictureInPictureParams` — so auto-enter never armed, leaving only
`onUserLeaveHint`, which the swipe-home gesture does not send.
**Fix.** `mpv`'s `video-params/dw`/`dh` are observed and carried through
`nativeState` into `MpvPlaybackState`, giving the real aspect, clamped to the
range Android will accept rather than left to be refused outright on a 2.40:1
release. `MainActivity` folds those plus the paused flag into a
`PictureInPictureShape` and re-registers `setPictureInPictureParams` whenever it
changes, which is what arms auto-enter; the params carry previous/play-pause/next
`RemoteAction`s and a source-rect hint so the transition shrinks the video
already on screen. The remote actions are delivered by a runtime-registered,
non-exported `BroadcastReceiver` rather than a service intent, PiP being one of
the states in which a background service start is refused.
`onPictureInPictureModeChanged` drives a flag through `ProtonStreamApp` into
`PlayerScreen`, which then draws the picture and nothing else — no title bar, no
transport, no skip offer, no up-next card — and restores the controls on the way
out rather than leaving them mid-timeout.
**Verified.** Compiles and packages; `check` clean, and the
`PictureInPictureIssue` lint entry is gone from the baseline rather than
suppressed. The `picture-in-picture` acceptance case is now implementable — it
was `pending` precisely because the assertion would have had to assert this bug.
### B45 — the play/pause button did nothing, everywhere
**Symptom.** Once an episode was open, every transport that can pause it was
dead: the player's own button, the media notification's, the one in the
notification shade's media control, and the lock screen's. All four drew "Play"
over a file that was plainly playing, and pressing them changed nothing.
**Cause.** `Player::load` (`pstr_mpv.cpp`) resets `state_` to a fresh
`PlaybackState` before issuing `loadfile`, deliberately, so that the outgoing
file's clock is not read under the incoming file's name — and `PlaybackState`
defaults `paused` to `true`. But `pause` is a property of the mpv *core*, not of
the file: mpv carries it across `loadfile` unchanged and therefore never
re-publishes it. The observer that would have corrected the reset only fires on
a change that never comes, so `state_.paused` stayed `true` for the whole
episode.
Everything downstream believed it. The transport drew Play, and
`host.setPaused(!state.paused)` therefore sent *unpause* to a core that had
never paused — a no-op, and the icon did not move afterwards either.
`PlaybackService` published `STATE_PAUSED` for the same reason, so the shade's
media control offered Play and its `onPlay` took the same no-op path. Only
volume and mute escaped, being the two fields `load` already carried across.
**Fix.** `load` preserves `paused` alongside `volume` and `muted`, with the
reason recorded where the reset is. `Player::pause` also writes the flag through
to `state_` rather than waiting for the observer, so a tap moves the transport on
the frame it happens rather than on the next poll a quarter second later, and
the UI is right even if the property event is ever lost. `NativeMpvHost.setPaused`
mirrors that write into `MpvPlaybackState` and notifies the service, so the
notification flips with the button.
**Verified.** Compiles and packages for both shipped ABIs; `check` passes with
no new lint findings. The behaviour itself needs the `playback` acceptance case
on a device.
### B43 — the acceptance harness could not run against the shipping build
**Symptom.** The first run on real hardware failed `native-libraries` on an APK
that plainly contained them, and could not have run against a release APK at all.
**Cause.** Four defects, none of which a device-free run could expose. The
package name and the activity class were treated as sharing a prefix, so `launch`
built `<package>/.MainActivity` — the leading dot expands against the *package*
argument, which on a debug build names `io.narl.protonstream.debug.MainActivity`,
a class that does not exist. `PACKAGE` was hardcoded to the debug applicationId,
so pointing the suite at a release APK found nothing installed. The
`native-libraries` case listed the on-device `lib/` directory, which
`useLegacyPackaging = false` leaves legitimately empty because the libraries are
mapped straight out of the APK. And `assert_no_crash` filtered logcat on
`PACKAGE.split(".")[0]` — the string `"io"` — then failed on any `FATAL` line,
so `offline-launch` failed on the Google Play services crash that its own
airplane-mode toggle provokes.
**Fix.** The activity is named by its fully-qualified class; `--release`,
`--package` and `--apk` resolve the APK and applicationId together;
`native-libraries` pulls the installed `base.apk` and reads its `lib/<abi>/`
entries, which also works under `--no-install`; crash detection is scoped to the
app's own process by the `Process:` line the runtime prints under a
`FATAL EXCEPTION` and by `>>> <package> <<<` in a native tombstone. Two further
fixes came out of the same run: the `instrumentation` case shelled `gradlew`
without `JAVA_HOME` or `ANDROID_TEST_BUILD_TYPE`, so it inherited a JDK 26 and
asked for a task that only exists when `testBuildType` is `release`; and cases
that start the app now wake and unlock the screen first, because a dark screen has
no resumed activity and the failure looks like a crashed app.
**Verified.** Full matrix green on a Pixel 6 (API 37) against the signed release
APK; see [B42](#b42--nothing-verifies-any-of-the-above-on-a-device).
### B44 — R8 broke the app under instrumentation in five places
**Symptom.** `connectedReleaseAndroidTest` died in `newApplication` before a
single test ran. Fixing that produced another crash, five times over.
**Cause.** The instrumentation APK is minified in its own R8 run that consumes
the app's mapping file and links against the app's classes at runtime. That
arrangement breaks in two ways: the mapping carries renames but not R8's
parameter-permutation optimisation, and a class only the test APK reaches is
unreachable from the app's call graph, so R8 removes it from the app while the
test APK does not bundle it. In order:
1. R8 rewrote `Intrinsics.checkNotNullParameter(Object, String)` to take
`(String, Object)`. Every Kotlin class in both APKs calls it on entry —
`NoSuchMethodError: No static method f(Ljava/lang/Object;Ljava/lang/String;)V
in class Lo6/k;`.
2. `androidx.tracing.Trace`, which `AndroidJUnitRunner.onCreate` uses and nothing
in the app reaches, was dropped.
3. `androidx.work.WorkManager` is absent from `mapping.txt` entirely rather than
renamed: the app only touches the implementation, so R8 vertically merged the
abstract class into `WorkManagerImpl` and JUnit's field scan could not
construct the test class.
4. `DownloadStateStore.clear()` has exactly one call site (`AppViewModel`), so R8
inlined it and dropped the method — invisible to the app, fatal to a caller
that resolves it by name.
5. The same for `SettingsStore.setWifiOnly`, and then for the synthesised
`DownloadCoordinatorKt` facade that holds the package's top-level functions.
**Fix.** `android/app/proguard-instrumentation.pro`, applied to the release
build, with each keep annotated by the crash it repairs. Only the last group is
app code, and the file states the trade-off plainly: those classes are no longer
optimised in the shipping build, so the suite no longer proves R8 handles them —
a narrow loss, since the surfaces R8 has actually broken here are the FFI ones,
and those are kept for functional reasons in `proguard-rules.pro` and stay under
test. The APK grew from 102.6 MB to 105.1 MB, roughly 2.4%, on a package that is
almost entirely native libraries.
**Verified.** 20/20 instrumentation tests pass on the signed minified release
APK, including the whole UniFFI boundary and the Keystore crypto suite — which is
the first direct evidence that R8 does not break the FFI.
### B13 — the player's controls ran off the bottom of the window ### B13 — the player's controls ran off the bottom of the window
**Symptom.** On the player page the transport row — previous, ±seconds, play, **Symptom.** On the player page the transport row — previous, ±seconds, play,
+108
View File
@@ -170,6 +170,114 @@ one failure the smoke test can see but a short run cannot.
`Cannot load libcuda.so.1` on stderr is mpv's `hwdec=auto-safe` probe declining a `Cannot load libcuda.so.1` on stderr is mpv's `hwdec=auto-safe` probe declining a
backend. Not an error. backend. Not an error.
## Android
Three layers, and the split is deliberate: anything that can run on a host does,
anything that needs Android's own implementations runs on a device, and anything
that needs a viewer's eyes is a scripted case rather than a paragraph in a
checklist.
```bash
bash scripts/build-android.sh check # ktlint, Android lint, host tests
bash scripts/android-acceptance.sh --list # what the device suite covers
bash scripts/android-acceptance.sh # debug APK, one attached device
bash scripts/android-acceptance.sh --release # the signed minified APK that ships
```
Prefer `--release`. R8 is the step that has actually broken this app, in five
distinct ways no debug run could reproduce (B44), and the release path is the
only one that exercises it. It needs the four `ANDROID_RELEASE_*` signing
variables from `docs/ANDROID.md` and a release APK built beforehand.
`check` needs no device and no account. It picks JDK 17 itself — a newer JVM
does not report a version error, it throws `IllegalArgumentException: 26.0.2` out
of a Kotlin version parser, which reads as a broken build.
**Host tests** (`app/src/test`) run under Robolectric, pinned to API 34 because
Robolectric ships no image for the app's targetSdk of 36, and against a plain
`Application` because the real one calls `System.loadLibrary("pstr_android")`,
which has no host build. What is pinned today is `DownloadStateStore`: the JSON
round trip, a corrupt row being skipped rather than failing the whole list, and
the two decode fallbacks that are easy to get backwards — a missing `label`
falling back to the link id, and a blank `error` reading as *no* error rather
than as a permanently failed download.
**Instrumentation tests** (`app/src/androidTest`) are the things with no host
equivalent:
- **`KeystoreSecretStore`** — round trip including non-ASCII, that the stored
form is not the plaintext, and that **every write draws a fresh IV** (GCM under
IV reuse leaks the XOR of two plaintexts). Then the failure modes, which are
the point: a tampered payload, a truncated one, and a *destroyed key* — the
lockscreen-change and device-restore case — must fail the read without taking
the process down, and the store must still accept a re-entered secret.
- **`UniFfiBoundaryTest`** — the first thing to cross the FFI on real hardware,
deliberately offline. It proves the ABI, the generated bindings, the callback
interface and the SQLite open, so a failure here with a green Rust workspace
means packaging: a missing ABI in `jniLibs`, a stale binding, or an R8 rule.
Under `--release` this is the direct evidence that R8 does not break the FFI —
the question `proguard-rules.pro` exists to answer, and which nothing else in
the tree checks.
- **`DownloadCoordinatorInstrumentedTest`** — what reaches WorkManager: the
unique-work identity pause and resume depend on, cancel-by-share not touching
another share, and the exact tag set, since tags are persisted unencrypted and
pinning them is what makes an accidental addition visible in review.
**The device suite** (`scripts/android-acceptance.sh`) is the matrix
`docs/ANDROID.md` requires before Android work is submitted. Cases carry one of
three dispositions, and the third is the one to read:
| | meaning |
|---|---|
| `xfail` | a known-open bug in `docs/BUGS.md`. Failing is expected; **passing fails the run**, because the bug is fixed and the case should be promoted |
| `pending` | specified but not implemented. Never passes, and the summary reports the matrix as incomplete until it is gone |
| `skip` | needs a share that was not configured |
Today `process-recreation` is `xfail` against B35, and `picture-in-picture` is
`pending` against B40 — asserting today's behaviour would encode the bug rather
than catch it. **The matrix is not complete, and the runner says so on every
run.**
The five live cases need a share, given as environment variables that reach the
device as instrumentation arguments and are written nowhere:
```bash
PSTR_ACCEPTANCE_SHARE_URL='https://drive.proton.me/urls/...#...' \
PSTR_ACCEPTANCE_SHARE_PASSWORD='...' \
bash scripts/android-acceptance.sh --release
```
Without them those cases report `skip`. They are read-only against the share —
nothing uploads, renames or deletes anything remote — but `download-cancel-resume`
does pull real bytes, so expect traffic. `playback` is the one that matters most:
it asserts libmpv's position advances, which only happens if decrypted bytes
crossed block storage, the Rust C ABI and the JNI adapter into the decoder. All
five share a single instrumentation run, because each one re-crawls the share
from an empty catalog otherwise — about six minutes apiece.
Lint runs against a **baseline**, which is not an exemption: every entry in
`android/app/lint-baseline.xml` maps to an open bug — the four
SharedPreferences `commit()` calls are B20, the PiP one is B40 — and the file
should get shorter as those land. Regenerate it with `gradlew updateLintBaseline`
after a fix, never to silence something unrelated. ktlint runs a narrow ruleset
for the same reason: the full standard set reports ~540 findings on this tree,
almost all line-wrapping preference, and a mechanical reformat would bury the
changes that matter. `android/.editorconfig` says which rules are on and why.
Two things about the release path worth knowing before they surprise you. The
lint baseline is generated by the `lint` task, and `lintVitalRelease` reports it
as created for a different variant, so the release lint run is not actually
baseline-checked — it currently finds nothing either way, but do not read a green
`lintVitalRelease` as the baseline holding. And `proguard-instrumentation.pro`
holds keeps that exist *only* so the instrumentation APK can link against the
minified app; they ship. The file explains each one and the fidelity it costs.
Adding to it is the normal response to a `NoSuchMethodError` or
`NoClassDefFoundError` that appears only under `--release`.
Cases that start the app wake and unlock the screen first, and a device behind a
PIN reports `skip` rather than a misleading failure — a dark screen has no
resumed activity, which looks exactly like a crashed app.
## SDK-side ## SDK-side
The streaming surface this app depends on is tested in `../proton-sdk-rs`: The streaming surface this app depends on is tested in `../proton-sdk-rs`:
+867
View File
@@ -0,0 +1,867 @@
#!/usr/bin/env python3
"""On-device acceptance for the Android client.
Driven by scripts/android-acceptance.sh. Every case states what it observes and
why it matters; a case that cannot run says so rather than passing quietly.
Cases carry one of three dispositions:
normal expected to pass; a failure fails the run
xfail a known-open bug in docs/BUGS.md. Failing is expected and does not
fail the run; *passing* fails it, because the bug is fixed and the
case should be promoted
pending the case is specified but not implemented. It never passes, and the
summary reports the matrix as incomplete until it is gone
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
import zipfile
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
REPO_ROOT = Path(__file__).resolve().parent.parent
# The debug build carries an applicationId suffix, so the installed package and
# the activity's class name do not share a prefix. Keeping them apart matters:
# `am start -n <pkg>/.MainActivity` expands the leading dot against the *package*
# argument, which on a debug build names a class that does not exist.
APPLICATION_ID = "io.narl.protonstream"
MAIN_ACTIVITY_CLASS = "io.narl.protonstream.MainActivity"
# Resolved in main() from --package/--apk; the release build has no suffix.
PACKAGE = f"{APPLICATION_ID}.debug"
LIVE_TEST_CLASS = "io.narl.protonstream.live.LiveShareTest"
DEBUG_APK = REPO_ROOT / "android/app/build/outputs/apk/debug/app-debug.apk"
RELEASE_APK = REPO_ROOT / "android/app/build/outputs/apk/release/app-release.apk"
class CaseError(Exception):
"""A case failed on an assertion it makes about the device."""
class CaseSkipped(Exception):
"""A case cannot run here — missing configuration, wrong device."""
class CasePending(Exception):
"""A case is specified but not implemented."""
@dataclass
class Case:
name: str
what: str
run: Callable[["Device", "Options"], None]
xfail: str | None = None
needs_share: bool = False
REGISTRY: list[Case] = []
def case(name: str, what: str, xfail: str | None = None, needs_share: bool = False):
def register(fn):
REGISTRY.append(Case(name=name, what=what, run=fn, xfail=xfail, needs_share=needs_share))
return fn
return register
# --------------------------------------------------------------------------
# device
class Device:
def __init__(self, serial: str | None, timeout: int):
self.serial = serial
self.timeout = timeout
self._base_apk: Path | None = None
def _adb(self, *args: str) -> list[str]:
prefix = ["adb"]
if self.serial:
prefix += ["-s", self.serial]
return prefix + list(args)
def run(self, *args: str, check: bool = True, timeout: int | None = None) -> str:
proc = subprocess.run(
self._adb(*args),
capture_output=True,
text=True,
timeout=timeout or self.timeout,
)
if check and proc.returncode != 0:
raise CaseError(
f"adb {' '.join(args)} failed ({proc.returncode}): "
f"{(proc.stderr or proc.stdout).strip()}"
)
return proc.stdout
def shell(self, command: str, check: bool = True, timeout: int | None = None) -> str:
return self.run("shell", command, check=check, timeout=timeout)
def prop(self, name: str) -> str:
return self.shell(f"getprop {name}").strip()
@property
def api_level(self) -> int:
return int(self.prop("ro.build.version.sdk") or 0)
@property
def abi(self) -> str:
return self.prop("ro.product.cpu.abi")
def pid(self) -> int | None:
out = self.shell(f"pidof {PACKAGE}", check=False).strip()
return int(out.split()[0]) if out else None
def is_installed(self) -> bool:
return bool(self.shell(f"pm list packages {PACKAGE}", check=False).strip())
def wake(self) -> None:
# A dark or locked screen has no resumed activity, so every case that
# starts the app would fail for a reason that has nothing to do with the
# app. `dismiss-keyguard` clears a swipe lock; a PIN or pattern needs a
# human, which is what `assert_unlocked` reports.
self.shell("input keyevent KEYCODE_WAKEUP", check=False)
self.shell("wm dismiss-keyguard", check=False)
time.sleep(0.5)
def keyguard_locked(self) -> bool:
out = self.shell("dumpsys window", check=False)
match = re.search(r"mDreamingLockscreen=(\w+)", out)
return bool(match and match.group(1) == "true")
def launch(self) -> None:
self.wake()
self.shell(f"am start -W -n {PACKAGE}/{MAIN_ACTIVITY_CLASS}")
def force_stop(self) -> None:
self.shell(f"am force-stop {PACKAGE}", check=False)
def clear_logcat(self) -> None:
self.run("logcat", "-c", check=False)
def logcat_since_clear(self) -> str:
return self.run("logcat", "-d", check=False)
def wait_for_process(self, seconds: float = 15.0) -> int:
deadline = time.time() + seconds
while time.time() < deadline:
pid = self.pid()
if pid:
return pid
time.sleep(0.25)
raise CaseError(f"{PACKAGE} did not start within {seconds:.0f}s")
def pull_base_apk(self) -> Path:
# Pulled from the device rather than read from build/outputs, so the
# case reports on what is actually installed even under --no-install.
if self._base_apk is None:
paths = [
line.removeprefix("package:").strip()
for line in self.shell(f"pm path {PACKAGE}").splitlines()
if line.startswith("package:")
]
base = next((path for path in paths if path.endswith("base.apk")), None)
if base is None:
raise CaseError(f"pm path reported no base.apk for {PACKAGE}")
local = Path(tempfile.mkdtemp(prefix="pstr-acceptance-")) / "base.apk"
self.run("pull", base, str(local), timeout=600)
if not local.is_file():
raise CaseError(f"could not pull {base} off the device")
self._base_apk = local
return self._base_apk
def resumed_activity(self) -> str:
out = self.shell("dumpsys activity activities | grep -E 'mResumedActivity|topResumedActivity'", check=False)
return out.strip()
def assert_no_crash(device: Device) -> None:
# Every check here is scoped to this app's own process. logcat is
# system-wide, and a case that changes global state provokes crashes in
# unrelated software: `offline-launch` toggles airplane mode, which reliably
# kills Google Play services with a FATAL EXCEPTION that has nothing to do
# with us. Attributing that to the app under test makes the suite lie.
lines = device.logcat_since_clear().splitlines()
owner = re.compile(r"\bProcess:\s*" + re.escape(PACKAGE) + r"\b")
for index, line in enumerate(lines):
if re.search(r"\bANR in " + re.escape(PACKAGE), line):
raise CaseError(f"ANR in logcat:\n" + "\n".join(lines[index : index + 20]))
if re.search(r"FATAL EXCEPTION", line, re.I):
# The runtime prints `Process: <package>, PID: <n>` on the line
# right after the FATAL EXCEPTION header.
if any(owner.search(nxt) for nxt in lines[index : index + 4]):
raise CaseError("crash in logcat:\n" + "\n".join(lines[index : index + 30]))
if "Fatal signal" in line:
# A native crash names the process in the tombstone header a few
# lines below, as `>>> <package> <<<`.
window = lines[index : index + 12]
if any(f">>> {PACKAGE} <<<" in nxt for nxt in window):
raise CaseError("native crash in logcat:\n" + "\n".join(window))
# --------------------------------------------------------------------------
# cases
@case("install", "the app is installed and its version is the workspace version")
def _install(device: Device, options: "Options") -> None:
if not device.is_installed():
raise CaseError(f"{PACKAGE} is not installed; run without --no-install")
dumped = device.shell(f"dumpsys package {PACKAGE} | grep versionName")
match = re.search(r"versionName=([^\s]+)", dumped)
if not match:
raise CaseError("dumpsys reported no versionName")
installed = match.group(1).removesuffix("-debug")
cargo = (REPO_ROOT / "Cargo.toml").read_text()
expected = re.search(r'(?m)^version\s*=\s*"([^"]+)"', cargo)
if not expected:
raise CaseError("no workspace version in Cargo.toml")
if installed != expected.group(1):
raise CaseError(
f"installed {installed} but the workspace is {expected.group(1)}; "
"the APK is stale — rebuild before trusting this run"
)
@case("native-libraries", "every native library the app dlopens is packaged for this ABI")
def _native_libraries(device: Device, options: "Options") -> None:
# The three that must be there: the Rust bridge, the GPL libmpv, and the
# JNI/EGL adapter between them. A missing one is a packaging fault that only
# shows up when the viewer presses play.
#
# The APK is the authority, not the on-device `lib/` directory. With
# `useLegacyPackaging = false` the installer never extracts anything — the
# libraries are mapped straight out of the (uncompressed, page-aligned) APK,
# so that directory is legitimately empty on a healthy install. Reading
# /proc/<pid>/maps would be the direct runtime proof, but it is unreadable
# for another app's process without root.
apk = device.pull_base_apk()
try:
with zipfile.ZipFile(apk) as archive:
entries = archive.namelist()
except zipfile.BadZipFile as error:
raise CaseError(f"the installed APK is not readable as a zip: {error}") from None
abis = sorted({name.split("/")[1] for name in entries if name.startswith("lib/") and "/" in name[4:]})
if device.abi not in abis:
raise CaseError(
f"the device is {device.abi} but the APK packages only {', '.join(abis) or '(no ABI)'}"
)
packaged = {name.rsplit("/", 1)[1] for name in entries if name.startswith(f"lib/{device.abi}/")}
missing = [
name
for name in ("libpstr_android.so", "libmpv.so", "libpstr_mpv.so")
if name not in packaged
]
if missing:
raise CaseError(
f"not packaged for {device.abi}: {', '.join(missing)}\n"
f"saw: {', '.join(sorted(packaged)) or '(nothing)'}"
)
@case("manifest-hardening", "backup stays closed and no unexpected component is exported")
def _manifest_hardening(device: Device, options: "Options") -> None:
dumped = device.shell(f"dumpsys package {PACKAGE}")
flags = re.search(r"flags=\[([^\]]*)\]", dumped)
if flags and "ALLOW_BACKUP" in flags.group(1):
raise CaseError(
"ALLOW_BACKUP is set: share fragments and link passwords would leave the device"
)
# Only MainActivity may be exported. Both services are internal, and anything
# that can reach them inherits the daemon's authenticated session.
exported = re.findall(r"^\s+(\S+/\S+)\s+filter", dumped, re.M)
unexpected = [name for name in exported if "MainActivity" not in name]
if unexpected:
raise CaseError(f"unexpectedly exported components: {', '.join(sorted(set(unexpected)))}")
@case("cold-start", "a cold launch reaches a resumed activity without a crash")
def _cold_start(device: Device, options: "Options") -> None:
device.force_stop()
device.clear_logcat()
device.launch()
device.wait_for_process()
time.sleep(2.0)
assert_no_crash(device)
resumed = device.resumed_activity()
if APPLICATION_ID not in resumed:
if device.keyguard_locked():
raise CaseSkipped("the device is locked and needs a PIN; unlock it and run again")
raise CaseError(f"no resumed activity for the app: {resumed or '(dumpsys reported none)'}")
@case("rotation", "rotating does not recreate the process or drop the activity")
def _rotation(device: Device, options: "Options") -> None:
device.force_stop()
device.clear_logcat()
device.launch()
before = device.wait_for_process()
try:
device.shell("settings put system accelerometer_rotation 0")
for rotation in ("1", "0"):
device.shell(f"settings put system user_rotation {rotation}")
time.sleep(1.5)
after = device.pid()
if after != before:
raise CaseError(f"the process was recreated by a rotation ({before} -> {after})")
assert_no_crash(device)
finally:
device.shell("settings put system user_rotation 0", check=False)
device.shell("settings put system accelerometer_rotation 1", check=False)
@case("tablet-layout", "a tablet-sized window lays out and survives a resize")
def _tablet_layout(device: Device, options: "Options") -> None:
original_size = device.shell("wm size").strip()
original_density = device.shell("wm density").strip()
try:
device.force_stop()
device.clear_logcat()
device.shell("wm size 1600x2560")
device.shell("wm density 240")
device.launch()
device.wait_for_process()
time.sleep(2.5)
assert_no_crash(device)
finally:
device.shell("wm size reset", check=False)
device.shell("wm density reset", check=False)
_ = (original_size, original_density)
@case(
"process-recreation",
"state survives the process being killed under the app",
)
def _process_recreation(device: Device, options: "Options") -> None:
# Was an xfail against B35, promoted when B35 landed: the navigation
# destination and the open player are `rememberSaveable` keys now, so there
# is saved instance state to come back to.
device.force_stop()
device.launch()
device.wait_for_process()
time.sleep(2.0)
device.shell(f"am broadcast -a android.intent.action.MAIN -p {PACKAGE}", check=False)
saved = device.shell(
f"dumpsys activity {PACKAGE} | grep -c 'mSavedInstanceState'", check=False
).strip()
device.clear_logcat()
device.shell(f"am kill {PACKAGE}")
time.sleep(1.0)
device.launch()
device.wait_for_process()
time.sleep(2.0)
assert_no_crash(device)
if saved in ("", "0"):
raise CaseError(
"no saved instance state was recorded, so nothing can be restored "
"after a process kill (B35)"
)
@case("offline-launch", "the app starts and stays up with no network")
def _offline_launch(device: Device, options: "Options") -> None:
device.force_stop()
device.shell("cmd connectivity airplane-mode enable", check=False)
time.sleep(2.0)
try:
device.clear_logcat()
device.launch()
device.wait_for_process()
time.sleep(3.0)
assert_no_crash(device)
finally:
device.shell("cmd connectivity airplane-mode disable", check=False)
time.sleep(2.0)
def jdk17_home() -> str | None:
candidates = [os.environ.get("JAVA_HOME", "")]
candidates += [
"/usr/lib/jvm/java-17-openjdk",
"/usr/lib/jvm/java-17-openjdk-amd64",
"/usr/lib/jvm/temurin-17-jdk",
"/Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home",
]
for home in candidates:
java = Path(home) / "bin/java" if home else None
if java is None or not java.is_file():
continue
try:
out = subprocess.run(
[str(java), "-version"], capture_output=True, text=True, timeout=30
)
except (OSError, subprocess.SubprocessError):
continue
first = (out.stderr + out.stdout).splitlines()[:1]
if first and re.search(r'version "17[.\"]', first[0]):
return home
return None
def android_sdk_home() -> str | None:
adb = shutil.which("adb")
if adb:
# <sdk>/platform-tools/adb
sdk = Path(adb).resolve().parent.parent
if (sdk / "platform-tools").is_dir():
return str(sdk)
for candidate in ("/opt/android-sdk", str(Path.home() / "Android/Sdk")):
if Path(candidate, "platform-tools").is_dir():
return candidate
return None
def redact(text: str, share: "dict[str, str] | None") -> str:
for value in (share or {}).values():
if value:
text = text.replace(value, "<redacted>")
return text
def run_instrumentation(
options: "Options",
only: str | None = None,
exclude: str | None = None,
share: "dict[str, str] | None" = None,
) -> None:
gradlew = REPO_ROOT / "android/gradlew"
gradle = [str(gradlew)] if gradlew.is_file() and os.access(gradlew, os.X_OK) else None
if gradle is None:
if not shutil.which("gradle"):
raise CaseSkipped("no android/gradlew and no gradle on PATH")
gradle = ["gradle"]
task = "connectedReleaseAndroidTest" if options.test_release else "connectedDebugAndroidTest"
env = dict(os.environ)
# `testBuildType` is read from this variable in build.gradle.kts, so without
# it Gradle configures the debug variant and the release task simply does
# not exist.
env["ANDROID_TEST_BUILD_TYPE"] = "release" if options.test_release else "debug"
# Same reason build-android.sh looks for it: on a newer JVM the Kotlin
# compiler throws `IllegalArgumentException: 26.0.2` out of an IntelliJ
# version parser, which reads as a broken build rather than a wrong JDK.
java_home = jdk17_home()
if java_home is None:
raise CaseSkipped("no JDK 17 found; set JAVA_HOME (see docs/ANDROID.md)")
env["JAVA_HOME"] = java_home
# There is no android/local.properties in the repo, so Gradle finds the SDK
# only through the environment. adb is on PATH here by definition, and it
# lives in the SDK, so its location is the most reliable hint we have.
if not env.get("ANDROID_HOME") and not env.get("ANDROID_SDK_ROOT"):
sdk = android_sdk_home()
if sdk is None:
raise CaseSkipped("no Android SDK found; set ANDROID_HOME (see docs/ANDROID.md)")
env["ANDROID_HOME"] = sdk
env["ANDROID_SDK_ROOT"] = sdk
arguments = list(gradle) + ["--no-daemon", task]
if only:
arguments.append(f"-Pandroid.testInstrumentationRunnerArguments.class={only}")
if exclude:
arguments.append(f"-Pandroid.testInstrumentationRunnerArguments.notClass={exclude}")
for name, value in (share or {}).items():
# Passed as a Gradle property rather than written anywhere: a share link
# and its password are credentials, and this keeps them out of the repo,
# the reports and the build directory. They are still visible in this
# machine's process list for the duration of the run.
arguments.append(f"-Pandroid.testInstrumentationRunnerArguments.{name}={value}")
proc = subprocess.run(
arguments,
cwd=REPO_ROOT / "android",
capture_output=True,
text=True,
timeout=max(options.timeout, 1800),
env=env,
)
if proc.returncode != 0:
tail = "\n".join(redact((proc.stdout + proc.stderr), share).splitlines()[-40:])
raise CaseError(f"{task} failed:\n{tail}")
if only and "Starting 0 tests" in proc.stdout:
raise CaseError(f"no test matched {only}")
@case("instrumentation", "the on-device test suite passes")
def _instrumentation(device: Device, options: "Options") -> None:
# The live class is excluded here and run by the cases below, so a missing
# share cannot make this case look like it covered them.
run_instrumentation(options, exclude=LIVE_TEST_CLASS)
def live_arguments(options: "Options") -> dict[str, str]:
share = {"pstr.shareUrl": options.share_url or ""}
if options.share_password:
share["pstr.sharePassword"] = options.share_password
return share
def _pending(reason: str):
def run(device: Device, options: "Options") -> None:
raise CasePending(reason)
return run
# One run of the live class serves every live case. Running them separately
# would be cleaner in principle, but each run re-adds the share and re-crawls it
# from an empty catalog — six minutes apiece against a real share — and the
# crawl is not what any of these cases is testing.
LIVE_RUN: "dict[str, tuple[str, str]] | None" = None
def live_results(options: "Options") -> "dict[str, tuple[str, str]]":
global LIVE_RUN
if LIVE_RUN is not None:
return LIVE_RUN
results_dir = REPO_ROOT / "android/app/build/outputs/androidTest-results/connected" / (
"release" if options.test_release else "debug"
)
for stale in results_dir.glob("*.xml"):
stale.unlink()
failure: CaseError | None = None
try:
run_instrumentation(options, only=LIVE_TEST_CLASS, share=live_arguments(options))
except CaseError as error:
# Individual methods are reported per case below; this only matters if
# nothing ran at all.
failure = error
parsed: dict[str, tuple[str, str]] = {}
for report in results_dir.glob("*.xml"):
for testcase in ET.parse(report).getroot().iter("testcase"):
name = testcase.get("name") or ""
problem = next(iter(testcase.iter("failure")), None)
if problem is not None:
parsed[name] = ("failed", (problem.text or "").strip())
elif next(iter(testcase.iter("skipped")), None) is not None:
parsed[name] = ("skipped", "the test skipped itself by assumption")
else:
parsed[name] = ("passed", "")
if not parsed:
raise failure or CaseError("the live suite produced no results")
LIVE_RUN = parsed
return parsed
def _live(method: str):
"""Reads one method out of the shared live run; the share is never stored."""
def run(device: Device, options: "Options") -> None:
results = live_results(options)
status, detail = results.get(method, ("missing", ""))
if status == "failed":
raise CaseError(detail or f"{method} failed")
if status == "skipped":
raise CaseSkipped(detail)
if status == "missing":
raise CaseError(f"{method} did not run; the live class may have been renamed")
return run
for _name, _what, _method in (
(
"playback",
"an episode decodes through libmpv from the encrypted stream",
"libmpvDecodesTheDecryptedStream",
),
(
"catalog",
"the share link resolves into a catalog of playable files",
"theShareResolvesIntoAPlayableCatalog",
),
(
"stream-open",
"an episode opens as a sized, seekable revision",
"openingAnEpisodeYieldsASeekableRevision",
),
(
"download-cancel-resume",
"a cancelled download leaves a resumable .part and resumes from it",
"aCancelledDownloadLeavesAResumablePartFile",
),
(
"watch-state",
"a resume position survives reopening the catalog",
"aResumePositionSurvivesReopeningTheCatalog",
),
):
REGISTRY.append(Case(name=_name, what=_what, run=_live(_method), needs_share=True))
# Still unimplemented, and the only case left that is. No longer blocked on the
# app: B40 landed, so there is correct behaviour to assert. What is missing is a
# driver — every live case here reads the engine and libmpv directly, and this
# one needs the activity with the player composed on screen before PiP means
# anything.
REGISTRY.append(
Case(
name="picture-in-picture",
what="entering PiP keeps playing and shows only video",
run=_pending(
"needs an activity-level driver: the live suite exercises the engine "
"and libmpv, not the composition, and PiP is a property of the window"
),
needs_share=True,
)
)
# --------------------------------------------------------------------------
# runner
@dataclass
class Options:
timeout: int = 120
fail_fast: bool = False
test_release: bool = False
share_url: str | None = None
share_password: str | None = None
@dataclass
class Result:
name: str
status: str # passed | failed | skipped | xfailed | xpassed | pending
detail: str = ""
seconds: float = 0.0
@dataclass
class Run:
results: list[Result] = field(default_factory=list)
def count(self, status: str) -> int:
return sum(1 for result in self.results if result.status == status)
def select(names: list[Case], only: str | None) -> list[Case]:
if not only:
return names
wanted = [part.strip() for part in only.split(",") if part.strip()]
return [case for case in names if any(part in case.name for part in wanted)]
def run_cases(device: Device, cases: list[Case], options: Options) -> Run:
run = Run()
for entry in cases:
started = time.time()
status, detail = "passed", ""
try:
# The share check comes second on purpose: an unimplemented case is
# pending whether or not a share is configured, and reporting it as
# "skipped" would let a missing environment variable disguise a hole
# in the matrix.
entry.run(device, options)
if entry.needs_share and not options.share_url:
raise CaseSkipped("PSTR_ACCEPTANCE_SHARE_URL is not set")
if entry.xfail:
status = "xpassed"
detail = f"{entry.xfail} looks fixed — promote this case and close it"
except CasePending as pending:
status, detail = "pending", str(pending)
except CaseSkipped as skipped:
status, detail = "skipped", str(skipped)
except (CaseError, subprocess.TimeoutExpired) as failure:
if entry.xfail:
status, detail = "xfailed", f"{entry.xfail}: {failure}"
else:
status, detail = "failed", str(failure)
seconds = time.time() - started
run.results.append(Result(entry.name, status, detail, seconds))
mark = {
"passed": "ok",
"failed": "FAIL",
"skipped": "skip",
"xfailed": "xfail",
"xpassed": "XPASS",
"pending": "pending",
}[status]
print(f"[{mark:>7}] {entry.name} ({seconds:.1f}s)")
if detail:
for line in detail.splitlines():
print(f" {line}")
if options.fail_fast and status in ("failed", "xpassed"):
break
return run
def write_json(run: Run, path: Path) -> None:
path.write_text(
json.dumps(
{
"results": [
{
"name": result.name,
"status": result.status,
"detail": result.detail,
"seconds": round(result.seconds, 3),
}
for result in run.results
]
},
indent=2,
)
)
def write_junit(run: Run, path: Path) -> None:
suite = ET.Element(
"testsuite",
name="android-acceptance",
tests=str(len(run.results)),
failures=str(run.count("failed") + run.count("xpassed")),
skipped=str(run.count("skipped") + run.count("pending")),
)
for result in run.results:
node = ET.SubElement(
suite, "testcase", name=result.name, time=f"{result.seconds:.3f}"
)
if result.status in ("failed", "xpassed"):
ET.SubElement(node, "failure", message=result.detail or result.status)
elif result.status in ("skipped", "pending", "xfailed"):
ET.SubElement(node, "skipped", message=result.detail or result.status)
ET.ElementTree(suite).write(path, encoding="utf-8", xml_declaration=True)
def attached_serials() -> list[str]:
out = subprocess.run(["adb", "devices"], capture_output=True, text=True).stdout
return [
line.split()[0]
for line in out.splitlines()[1:]
if line.strip() and line.split()[-1] == "device"
]
def main() -> int:
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("--serial")
parser.add_argument("--apk")
parser.add_argument(
"--package",
help="installed applicationId; inferred from the APK's build type when omitted",
)
parser.add_argument(
"--release",
action="store_true",
help="test the minified release APK — the build that actually ships",
)
parser.add_argument("--no-install", action="store_true")
parser.add_argument("--list", action="store_true")
parser.add_argument("--fail-fast", action="store_true")
parser.add_argument("--timeout", type=int, default=120)
parser.add_argument("--report-json")
parser.add_argument("--report-junit")
parser.add_argument("--keep-going-on-crash", action="store_true")
args = parser.parse_args()
release = args.release or os.environ.get("ANDROID_TEST_BUILD_TYPE") == "release"
apk = Path(args.apk) if args.apk else (RELEASE_APK if release else DEBUG_APK)
global PACKAGE
if args.package:
PACKAGE = args.package
elif "debug" in apk.name or "debug" in apk.parent.name:
PACKAGE = f"{APPLICATION_ID}.debug"
else:
PACKAGE = APPLICATION_ID
cases = select(REGISTRY, os.environ.get("PSTR_ACCEPTANCE_ONLY"))
if args.list:
width = max(len(entry.name) for entry in cases) if cases else 0
for entry in cases:
tags = []
if entry.xfail:
tags.append(f"xfail:{entry.xfail}")
if entry.needs_share:
tags.append("live")
suffix = f" [{', '.join(tags)}]" if tags else ""
print(f"{entry.name:<{width}} {entry.what}{suffix}")
return 0
if not cases:
print("no case matched PSTR_ACCEPTANCE_ONLY", file=sys.stderr)
return 2
serials = attached_serials()
serial = args.serial
if serial is None:
if len(serials) != 1:
print(
f"expected exactly one attached device, found {len(serials)}"
+ (f": {', '.join(serials)}" if serials else ""),
file=sys.stderr,
)
return 2
serial = serials[0]
device = Device(serial, args.timeout)
if device.api_level < 31:
print(
f"the app targets Android 12 (API 31) and newer; this device is API {device.api_level}",
file=sys.stderr,
)
return 2
if not args.no_install:
if not apk.is_file():
build = "release" if release else "debug"
print(
f"no APK at {apk}; run `bash scripts/build-android.sh {build}` first",
file=sys.stderr,
)
return 2
print(f"installing {apk.name} as {PACKAGE} on {serial} ({device.abi}, API {device.api_level})")
device.run("install", "-r", "-g", str(apk), timeout=600)
options = Options(
timeout=args.timeout,
fail_fast=args.fail_fast,
test_release=release,
share_url=os.environ.get("PSTR_ACCEPTANCE_SHARE_URL"),
share_password=os.environ.get("PSTR_ACCEPTANCE_SHARE_PASSWORD"),
)
run = run_cases(device, cases, options)
if args.report_json:
write_json(run, Path(args.report_json))
if args.report_junit:
write_junit(run, Path(args.report_junit))
print()
print(
" ".join(
f"{status}={run.count(status)}"
for status in ("passed", "failed", "xfailed", "xpassed", "skipped", "pending")
)
)
if run.count("pending"):
print(
f"the matrix docs/ANDROID.md requires is incomplete: "
f"{run.count('pending')} case(s) are specified but not implemented"
)
if run.count("xpassed"):
print("a case marked xfail passed — the bug it tracks is fixed; promote the case")
return 1 if run.count("failed") or run.count("xpassed") else 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
sys.exit(130)
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# On-device acceptance for the Android client. Drives a real device or emulator
# through the matrix docs/ANDROID.md requires before Android work is submitted.
set -Eeuo pipefail
usage() {
cat >&2 <<'EOF'
usage: scripts/android-acceptance.sh [OPTION ...]
Runs against one attached device or emulator. The app is installed from the
debug APK unless --no-install is given; nothing here needs a Proton account
except the cases marked "live", which skip cleanly without one.
Options:
--serial SERIAL target this device (default: the only attached one)
--apk PATH install this APK instead of the debug output
--no-install use whatever build is already installed
--list print every case and what it needs, then exit
--fail-fast stop at the first failure instead of continuing
--timeout SECONDS per-case limit (default 120)
--report-json PATH machine-readable results
--report-junit PATH JUnit XML for CI
--keep-going-on-crash
report a crash as a failure and continue rather than
aborting the run
Environment:
PSTR_ACCEPTANCE_ONLY=name[,name] run only these cases (substring match)
PSTR_ACCEPTANCE_SHARE_URL public link for the live cases
PSTR_ACCEPTANCE_SHARE_PASSWORD custom password, if the link needs one
Live cases are destructive to the app's own state: they clear app data between
phases. They never write to the share.
EOF
exit 2
}
case "${1:-}" in
-h|--help) usage ;;
esac
command -v python3 >/dev/null || { echo "python3 is required" >&2; exit 2; }
command -v adb >/dev/null || { echo "adb is required (Android platform-tools)" >&2; exit 2; }
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
exec python3 -u "$script_dir/android-acceptance.py" "$@"
+37 -2
View File
@@ -7,11 +7,14 @@ android_dir="$repo_root/android"
usage() { usage() {
echo "usage: $0 {debug|check|release|signed|all}" echo "usage: $0 {debug|check|release|signed|all}"
echo
echo " check ktlint, Android lint and host unit tests — no device needed."
echo " On-device coverage is scripts/android-acceptance.sh."
} }
case "${1:-}" in case "${1:-}" in
debug) tasks=(assembleDebug) ;; debug) tasks=(assembleDebug) ;;
check) tasks=(lintDebug testDebugUnitTest) ;; check) tasks=(ktlintCheck lintDebug testDebugUnitTest) ;;
release) tasks=(assembleRelease bundleRelease) ;; release) tasks=(assembleRelease bundleRelease) ;;
signed) signed)
required=( required=(
@@ -28,10 +31,42 @@ case "${1:-}" in
done done
tasks=(assembleRelease bundleRelease) tasks=(assembleRelease bundleRelease)
;; ;;
all) tasks=(lintDebug testDebugUnitTest assembleDebug assembleRelease bundleRelease) ;; all) tasks=(ktlintCheck lintDebug testDebugUnitTest assembleDebug assembleRelease bundleRelease) ;;
*) usage >&2; exit 2 ;; *) usage >&2; exit 2 ;;
esac esac
# The Android toolchain needs JDK 17. A newer JVM does not fail with a version
# message — the Kotlin compiler throws `IllegalArgumentException: 26.0.2` out of
# an IntelliJ version parser, which reads as a broken build rather than a wrong
# JDK. Find 17 before that can happen.
java_major() {
local java_bin="$1"
[[ -x "$java_bin" ]] || return 1
"$java_bin" -version 2>&1 | sed -n '1s/.*version "\([0-9]*\).*/\1/p'
}
if [[ "$(java_major "${JAVA_HOME:-}/bin/java" || true)" != "17" ]]; then
found=""
for candidate in \
/usr/lib/jvm/java-17-openjdk \
/usr/lib/jvm/java-17-openjdk-amd64 \
/usr/lib/jvm/temurin-17-jdk \
/Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home; do
if [[ "$(java_major "$candidate/bin/java" || true)" == "17" ]]; then
found="$candidate"
break
fi
done
if [[ -z "$found" ]] && [[ "$(java_major "$(command -v java || echo /nonexistent)" || true)" == "17" ]]; then
found="$(dirname "$(dirname "$(readlink -f "$(command -v java)")")")"
fi
if [[ -z "$found" ]]; then
echo "error: JDK 17 is required (see docs/ANDROID.md); set JAVA_HOME to it" >&2
exit 1
fi
export JAVA_HOME="$found"
fi
if [[ -x "$android_dir/gradlew" ]]; then if [[ -x "$android_dir/gradlew" ]]; then
gradle=("$android_dir/gradlew") gradle=("$android_dir/gradlew")
elif command -v gradle >/dev/null 2>&1; then elif command -v gradle >/dev/null 2>&1; then