mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
improved android client, feature parity with desktop
This commit is contained in:
1 parent
352fdf1933
commit
f1b3ad9637
56 files changed
+9721
-1793
No files matched your search
Generated
+8
-7
@@ -4262,7 +4262,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-android"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni 0.21.1",
|
||||
@@ -4281,7 +4281,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-app"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"eframe",
|
||||
@@ -4303,7 +4303,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-cli"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -4321,7 +4321,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-core"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"directories",
|
||||
"futures",
|
||||
@@ -4339,7 +4339,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-meta"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"pstr-core",
|
||||
"reqwest",
|
||||
@@ -4352,12 +4352,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-player"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"libloading",
|
||||
"libmpv2",
|
||||
"libmpv2-sys",
|
||||
"pstr-core",
|
||||
"pstr-stream",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
@@ -4366,7 +4367,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-stream"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"futures",
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/narrrl/proton-stream"
|
||||
|
||||
@@ -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
|
||||
@@ -5,6 +5,7 @@ plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.ktlint)
|
||||
}
|
||||
|
||||
val repositoryRoot = rootProject.projectDir.parentFile
|
||||
@@ -53,6 +54,7 @@ android {
|
||||
targetSdk = 36
|
||||
versionCode = releaseVersionCode
|
||||
versionName = releaseVersionName
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "x86_64")
|
||||
@@ -103,7 +105,51 @@ android {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
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.jna) { artifact { type = "aar" } }
|
||||
|
||||
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
@@ -219,5 +264,36 @@ dependencies {
|
||||
implementation(libs.androidx.compose.material3.adaptive)
|
||||
implementation(libs.androidx.compose.material3.navigation.suite)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
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) }
|
||||
@@ -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 = "8.13.2""
|
||||
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 = "2026.06.00""
|
||||
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 = "1.11.0""
|
||||
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 = "2.9.4""
|
||||
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 = "2.9.4""
|
||||
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 = "2.9.5""
|
||||
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 = "2.10.5""
|
||||
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 = "2.10.5""
|
||||
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 = "1.1.0""
|
||||
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 = "1.1.0""
|
||||
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 = "androidx.core:core-ktx", version = "1.17.0" }"
|
||||
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 = "2.2.21""
|
||||
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 = "2.2.21""
|
||||
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 = "5.17.0""
|
||||
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 = "4.16""
|
||||
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 = "13.1.0""
|
||||
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>
|
||||
@@ -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** { *; }
|
||||
Vendored
+16
@@ -7,3 +7,19 @@
|
||||
-keepclasseswithmembernames class * {
|
||||
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 { *; }
|
||||
@@ -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>;
|
||||
}
|
||||
+129
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+155
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
<activity
|
||||
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:launchMode="singleTask"
|
||||
android:supportsPictureInPicture="true"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "pstr_mpv.h"
|
||||
|
||||
#include <EGL/egl.h>
|
||||
#include <android/log.h>
|
||||
#include <android/native_window.h>
|
||||
#include <android/native_window_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 {
|
||||
c_boundary([=] {
|
||||
if (auto *stream = static_cast<StreamCookie *>(opaque)) {
|
||||
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 {
|
||||
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 {
|
||||
@@ -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 {
|
||||
double position = 0.0;
|
||||
double duration = 0.0;
|
||||
@@ -137,6 +177,30 @@ struct PlaybackState {
|
||||
bool paused = true;
|
||||
bool muted = 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 {
|
||||
@@ -165,6 +229,13 @@ class Player {
|
||||
observe("pause", 3, MPV_FORMAT_FLAG);
|
||||
observe("volume", 4, MPV_FORMAT_DOUBLE);
|
||||
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(); });
|
||||
renderer_ = std::thread([this] { render_loop(); });
|
||||
}
|
||||
@@ -213,23 +284,60 @@ class Player {
|
||||
bool load(uint64_t handle, double start, const std::string &audio,
|
||||
const std::string &subtitle, bool subtitles) {
|
||||
if (!mpv_) return false;
|
||||
{
|
||||
std::unique_lock lock(render_mutex_);
|
||||
if (!render_cv_.wait_for(lock, std::chrono::seconds(5), [this] { return render_ != nullptr || !running_.load(); })) return false;
|
||||
}
|
||||
// No wait for a render context. It is created with the render thread and
|
||||
// needs no window, and a load that cannot get one plays audio anyway —
|
||||
// waiting for a surface here is what made background playback fail after
|
||||
// a five-second stall.
|
||||
if (!audio.empty()) set_string("alang", audio);
|
||||
if (!subtitle.empty()) set_string("slang", subtitle);
|
||||
set_string("sid", subtitles ? "auto" : "no");
|
||||
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 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;
|
||||
}
|
||||
|
||||
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 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 select_track(bool audio, int64_t id) {
|
||||
const char *property = audio ? "aid" : "sid";
|
||||
@@ -246,6 +354,12 @@ class Player {
|
||||
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 {
|
||||
if (!mpv_) return "[]";
|
||||
int64_t count = 0;
|
||||
@@ -274,22 +388,115 @@ class Player {
|
||||
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:
|
||||
struct MpvFree {
|
||||
void operator()(char *value) const noexcept { if (value) mpv_free(value); }
|
||||
};
|
||||
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) {
|
||||
std::string out;
|
||||
if (!text) return out;
|
||||
for (const unsigned char c : std::string_view(text)) {
|
||||
if (c == '"' || c == '\\') out += '\\';
|
||||
if (c >= 0x20) out += static_cast<char>(c);
|
||||
const std::string_view input(text);
|
||||
for (size_t i = 0; i < input.size();) {
|
||||
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;
|
||||
}
|
||||
|
||||
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 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()); }
|
||||
@@ -306,25 +513,64 @@ class Player {
|
||||
if (start > 0.0) set_double("time-pos", start);
|
||||
}
|
||||
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_);
|
||||
// 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_.end_reason = end ? end_reason_of(end->reason) : EndReason::Other;
|
||||
} else if (event->event_id == MPV_EVENT_START_FILE) {
|
||||
std::lock_guard lock(state_mutex_);
|
||||
state_.loading = 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) {
|
||||
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) {
|
||||
if (!property.data) return;
|
||||
std::lock_guard lock(state_mutex_);
|
||||
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, "volume")) state_.volume = *static_cast<double *>(property.data);
|
||||
// `volume`, `mute` and `pause` belong to the core and carry across a
|
||||
// load; the rest describe the open file, and a reading of them that
|
||||
// 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, "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 {
|
||||
@@ -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() {
|
||||
EGLDisplay display = EGL_NO_DISPLAY;
|
||||
EGLContext context = EGL_NO_CONTEXT;
|
||||
@@ -343,6 +598,7 @@ class Player {
|
||||
EGLSurface pbuffer = EGL_NO_SURFACE;
|
||||
EGLConfig config = nullptr;
|
||||
ANativeWindow *window = nullptr;
|
||||
if (initialize_egl(display, context, config, pbuffer)) initialize_renderer();
|
||||
while (running_.load()) {
|
||||
std::unique_lock lock(render_mutex_);
|
||||
render_cv_.wait(lock, [this] { return !running_.load() || surface_changed_ || frame_ready_.load(); });
|
||||
@@ -353,18 +609,31 @@ class Player {
|
||||
if (window) ANativeWindow_release(window);
|
||||
window = pending_window_;
|
||||
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);
|
||||
window = nullptr;
|
||||
}
|
||||
if (window) {
|
||||
surface = eglCreateWindowSurface(display, config, window, nullptr);
|
||||
eglMakeCurrent(display, surface, surface, context);
|
||||
initialize_renderer();
|
||||
}
|
||||
}
|
||||
const bool draw = frame_ready_.exchange(false);
|
||||
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_) {
|
||||
eglMakeCurrent(display, surface, surface, context);
|
||||
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; }
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
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);
|
||||
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};
|
||||
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};
|
||||
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};
|
||||
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() {
|
||||
@@ -426,6 +717,8 @@ class Player {
|
||||
std::thread renderer_;
|
||||
mutable std::mutex state_mutex_;
|
||||
PlaybackState state_;
|
||||
/// The first error mpv logged about the open file, until it is consumed.
|
||||
std::string error_;
|
||||
std::mutex render_mutex_;
|
||||
std::condition_variable render_cv_;
|
||||
ANativeWindow *pending_window_ = nullptr;
|
||||
@@ -465,13 +758,17 @@ bool utf8(JNIEnv *env, jstring value, std::string &result) {
|
||||
void reject_null_surface(JNIEnv *env) noexcept {
|
||||
c_boundary([=] {
|
||||
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
|
||||
|
||||
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, [] {
|
||||
auto player = std::make_unique<Player>();
|
||||
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 {
|
||||
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 {
|
||||
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 jdouble values[] = {state.position, state.duration, state.volume,
|
||||
state.paused ? 1.0 : 0.0, state.muted ? 1.0 : 0.0,
|
||||
state.ended ? 1.0 : 0.0};
|
||||
jdoubleArray result = env->NewDoubleArray(6);
|
||||
state.ended ? 1.0 : 0.0,
|
||||
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);
|
||||
env->SetDoubleArrayRegion(result, 0, 6, values);
|
||||
env->SetDoubleArrayRegion(result, 0, kFields, values);
|
||||
if (env->ExceptionCheck()) return static_cast<jdoubleArray>(nullptr);
|
||||
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 {
|
||||
return c_boundary<jstring>(nullptr, [=] {
|
||||
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 {
|
||||
c_boundary([=] { pstr_android_stream_release(static_cast<uint64_t>(handle)); });
|
||||
}
|
||||
@@ -8,6 +8,9 @@ extern "C" {
|
||||
#endif
|
||||
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);
|
||||
/// 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);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
|
||||
@@ -1,72 +1,151 @@
|
||||
package io.narl.protonstream
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.os.Build
|
||||
import android.app.PendingIntent
|
||||
import android.app.PictureInPictureParams
|
||||
import android.app.RemoteAction
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
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.util.Rational
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import io.narl.protonstream.ui.ProtonStreamApp
|
||||
import io.narl.protonstream.ui.theme.ProtonStreamTheme
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.PlaybackService
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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() {
|
||||
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 val playbackConnection = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
playerHost.value = (service as PlaybackService.PlaybackBinder).host
|
||||
bound = true
|
||||
}
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
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(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { /* Downloads remain usable; Android suppresses their notifications if denied. */ }
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
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 (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
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 {
|
||||
ProtonStreamTheme {
|
||||
ProtonStreamApp(playerHost.value)
|
||||
ProtonStreamApp(playerHost.value, inPictureInPicture.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun 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) {
|
||||
enterPictureInPictureMode(
|
||||
PictureInPictureParams.Builder().setAspectRatio(Rational(16, 9)).setAutoEnterEnabled(true).build(),
|
||||
)
|
||||
runCatching {
|
||||
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() {
|
||||
if (bound) unbindService(playbackConnection)
|
||||
runCatching { unregisterReceiver(pictureInPictureControls) }
|
||||
if (bound) {
|
||||
unbindService(playbackConnection)
|
||||
bound = false
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@@ -80,4 +159,81 @@ class MainActivity : ComponentActivity() {
|
||||
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
|
||||
|
||||
import android.content.Context
|
||||
import android.os.StatFs
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.workDataOf
|
||||
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
|
||||
|
||||
object DownloadCoordinator {
|
||||
fun enqueue(context: Context, episode: EpisodeRecord) {
|
||||
enqueue(
|
||||
context,
|
||||
RetainedDownload(episode.shareId, episode.volumeId, episode.linkId, episode.label),
|
||||
ExistingWorkPolicy.KEEP,
|
||||
)
|
||||
}
|
||||
/**
|
||||
* Where bulk queueing runs.
|
||||
*
|
||||
* Callers are click handlers, and "Download show" on a long series is a
|
||||
* 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) {
|
||||
enqueue(context, download, ExistingWorkPolicy.REPLACE)
|
||||
}
|
||||
|
||||
private fun enqueue(context: Context, download: RetainedDownload, policy: ExistingWorkPolicy) {
|
||||
DownloadStateStore(context).put(
|
||||
download.copy(status = RetainedDownload.STATUS_QUEUED, error = null),
|
||||
val queued = 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(
|
||||
Constraints.Builder().setRequiredNetworkType(
|
||||
requiredNetworkType(SettingsStore(context).wifiOnly),
|
||||
).build(),
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(requiredNetworkType(wifiOnly))
|
||||
// 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(
|
||||
workDataOf(
|
||||
@@ -45,26 +159,6 @@ object DownloadCoordinator {
|
||||
.addTag("$EPISODE_TAG${download.label}")
|
||||
.addTag(shareTag(download.shareId))
|
||||
.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 workName(shareId: String, linkId: String) = "offline-$shareId-$linkId"
|
||||
@@ -72,6 +166,9 @@ object DownloadCoordinator {
|
||||
const val TAG = "offline-download"
|
||||
const val EPISODE_TAG = "offline-episode:"
|
||||
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 =
|
||||
|
||||
@@ -35,18 +35,59 @@ class DownloadStateStore(context: Context) {
|
||||
fun get(shareId: String, linkId: String): RetainedDownload? =
|
||||
preferences.getString(key(shareId, linkId), null)?.let { runCatching { decode(it) }.getOrNull() }
|
||||
|
||||
fun put(record: RetainedDownload) {
|
||||
preferences.edit().putString(record.key, encode(record)).commit()
|
||||
fun put(record: RetainedDownload) = synchronized(WRITES) {
|
||||
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) {
|
||||
val edit = preferences.edit()
|
||||
records().filter { it.shareId == shareId }.forEach { edit.remove(it.key) }
|
||||
edit.commit()
|
||||
fun remove(shareId: String, linkId: String) = synchronized(WRITES) {
|
||||
edit { remove(key(shareId, linkId)) }
|
||||
}
|
||||
|
||||
/** 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()
|
||||
@@ -78,5 +119,11 @@ class DownloadStateStore(context: Context) {
|
||||
|
||||
private companion object {
|
||||
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.content.pm.ServiceInfo
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.Data
|
||||
@@ -23,6 +24,7 @@ class OfflineDownloadWorker(
|
||||
parameters: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, parameters) {
|
||||
override suspend fun doWork(): Result {
|
||||
createNotificationChannel()
|
||||
setForeground(createForegroundInfo(0))
|
||||
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"))
|
||||
@@ -35,20 +37,43 @@ class OfflineDownloadWorker(
|
||||
store.put(retained)
|
||||
|
||||
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) {
|
||||
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()))
|
||||
setForegroundAsync(createForegroundInfo(percent))
|
||||
val requested = store.get(shareId, linkId)
|
||||
val requestedStatus = requested?.status
|
||||
retained = (requested ?: retained).copy(
|
||||
downloaded = downloaded.toLong(),
|
||||
total = total.toLong(),
|
||||
status = if (requestedStatus == RetainedDownload.STATUS_PAUSED ||
|
||||
requestedStatus == RetainedDownload.STATUS_CANCELLED
|
||||
) requestedStatus else RetainedDownload.STATUS_RUNNING,
|
||||
)
|
||||
store.put(retained)
|
||||
// Under the store's lock: `pause()` is a concurrent writer, and
|
||||
// this read-modify-write used to put RUNNING back over the
|
||||
// viewer's PAUSED and then report the download as cancelled.
|
||||
store.update(shareId, linkId) { stored ->
|
||||
val current = stored ?: retained
|
||||
current.copy(
|
||||
downloaded = downloaded.toLong(),
|
||||
total = total.toLong(),
|
||||
status = when (current.status) {
|
||||
RetainedDownload.STATUS_PAUSED,
|
||||
RetainedDownload.STATUS_CANCELLED,
|
||||
-> current.status
|
||||
else -> RetainedDownload.STATUS_RUNNING
|
||||
},
|
||||
).also { retained = it }
|
||||
}
|
||||
}
|
||||
|
||||
override fun isCancelled(): Boolean = isStopped
|
||||
@@ -62,9 +87,12 @@ class OfflineDownloadWorker(
|
||||
},
|
||||
onFailure = { failure ->
|
||||
if (isStopped) {
|
||||
val current = store.get(shareId, linkId) ?: retained
|
||||
if (current.status != RetainedDownload.STATUS_PAUSED) {
|
||||
store.put(current.copy(status = RetainedDownload.STATUS_CANCELLED))
|
||||
// A pause is also a stop. Under the lock, so the record read
|
||||
// here is the one the write lands on.
|
||||
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"))
|
||||
} else if (runAttemptCount < MAX_RETRIES) {
|
||||
@@ -79,11 +107,14 @@ class OfflineDownloadWorker(
|
||||
)
|
||||
}
|
||||
|
||||
private fun createForegroundInfo(progress: Int): ForegroundInfo {
|
||||
val manager = applicationContext.getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(
|
||||
/** Once per run, rather than once per 4 MiB block. */
|
||||
private fun createNotificationChannel() {
|
||||
applicationContext.getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_ID, "Offline downloads", NotificationManager.IMPORTANCE_LOW),
|
||||
)
|
||||
}
|
||||
|
||||
private fun createForegroundInfo(progress: Int): ForegroundInfo {
|
||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
|
||||
.setContentTitle("Making episode available offline")
|
||||
@@ -109,6 +140,9 @@ class OfflineDownloadWorker(
|
||||
const val KEY_TOTAL = "total"
|
||||
private const val MAX_RETRIES = 3
|
||||
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.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyPermanentlyInvalidatedException
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import java.security.KeyStore
|
||||
@@ -10,22 +11,44 @@ import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
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 {
|
||||
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||
|
||||
override fun set(key: String, value: String) {
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, encryptionKey())
|
||||
val encrypted = cipher.doFinal(value.toByteArray(Charsets.UTF_8))
|
||||
val payload = cipher.iv + encrypted
|
||||
override fun set(key: String, value: String) = bridging("store credential") {
|
||||
val payload = try {
|
||||
seal(value)
|
||||
} catch (invalidated: KeyPermanentlyInvalidatedException) {
|
||||
// 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())
|
||||
}
|
||||
|
||||
override fun get(key: String): String? {
|
||||
val encoded = preferences.getString(key, null) ?: return null
|
||||
private fun seal(value: String): ByteArray {
|
||||
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)
|
||||
require(payload.size > IV_SIZE) { "Invalid encrypted credential" }
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
@@ -34,13 +57,23 @@ class KeystoreSecretStore(context: Context) : AndroidSecretStore {
|
||||
encryptionKey(),
|
||||
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())
|
||||
}
|
||||
|
||||
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 {
|
||||
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
|
||||
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run {
|
||||
|
||||
@@ -12,14 +12,23 @@ object NativeRuntime {
|
||||
private lateinit var appContext: Context
|
||||
@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) {
|
||||
System.loadLibrary("pstr_android")
|
||||
initTls(context)
|
||||
tlsReady = initTls(context)
|
||||
appContext = context.applicationContext
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
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
|
||||
|
||||
import android.view.Surface
|
||||
import io.narl.protonstream.ui.ThumbnailSource
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -16,6 +17,31 @@ import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
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(
|
||||
val position: Double = 0.0,
|
||||
@@ -24,6 +50,53 @@ data class MpvPlaybackState(
|
||||
val paused: Boolean = true,
|
||||
val muted: 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(
|
||||
@@ -38,21 +111,61 @@ data class MpvTrack(
|
||||
}
|
||||
|
||||
/** Process-local owner of the libmpv core. PlaybackService owns its lifetime. */
|
||||
class NativeMpvHost : LibmpvHost, AutoCloseable {
|
||||
private val nativeHandle = nativeCreate().also { check(it != 0L) { "libmpv initialization failed" } }
|
||||
class NativeMpvHost private constructor(private val nativeHandle: Long) : LibmpvHost, AutoCloseable {
|
||||
private val lifecycle = ReentrantLock()
|
||||
@Volatile private var closed = false
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
private val mutableState = MutableStateFlow(MpvPlaybackState())
|
||||
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 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 onStateChanged: ((MpvPlaybackState) -> 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 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
|
||||
|
||||
override fun attachSurface(surface: Surface) {
|
||||
@@ -64,6 +177,7 @@ class NativeMpvHost : LibmpvHost, AutoCloseable {
|
||||
}
|
||||
|
||||
override suspend fun play(
|
||||
media: String,
|
||||
nativeHandle: ULong,
|
||||
size: ULong,
|
||||
startPosition: Double,
|
||||
@@ -72,69 +186,172 @@ class NativeMpvHost : LibmpvHost, AutoCloseable {
|
||||
subtitles: Boolean,
|
||||
) {
|
||||
check(size > 0uL) { "Cannot play an empty stream" }
|
||||
withContext(Dispatchers.IO) {
|
||||
val loaded = withOpenHandle {
|
||||
nativeLoad(it, nativeHandle.toLong(), startPosition, audioLanguage, subtitleLanguage, subtitles).also { loaded ->
|
||||
if (loaded) {
|
||||
currentStreamHandle?.let(::pstrAndroidStreamRelease)
|
||||
currentStreamHandle = nativeHandle.toLong()
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
val loaded = withOpenHandle {
|
||||
nativeLoad(it, nativeHandle.toLong(), startPosition, audioLanguage, subtitleLanguage, subtitles).also { loaded ->
|
||||
if (loaded) {
|
||||
// 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 {
|
||||
onPlaybackStarted?.invoke()
|
||||
startPolling()
|
||||
withOpenHandle {
|
||||
onPlaybackStarted?.invoke()
|
||||
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 setVolume(volume: Double) { withOpenHandle { nativeVolume(it, volume) } }
|
||||
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?) {
|
||||
val audio = track?.type == "audio"
|
||||
withOpenHandle { nativeSelectTrack(it, audio, track?.id ?: -1) }
|
||||
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() {
|
||||
val callback = withOpenHandle {
|
||||
nativeStop(it)
|
||||
currentStreamHandle?.let(::pstrAndroidStreamRelease)
|
||||
currentStreamHandle = null
|
||||
// Nothing loaded means nothing to attribute a reading to: a save
|
||||
// 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
|
||||
}
|
||||
callback?.invoke()
|
||||
}
|
||||
|
||||
internal fun releaseEndedStream() {
|
||||
withOpenHandle { currentStreamHandle?.let(::pstrAndroidStreamRelease); currentStreamHandle = null }
|
||||
}
|
||||
|
||||
override fun releaseNativeStream(nativeHandle: ULong) = pstrAndroidStreamRelease(nativeHandle.toLong())
|
||||
|
||||
private fun startPolling() {
|
||||
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
|
||||
while (isActive) {
|
||||
val values = withOpenHandle(::nativeState) ?: break
|
||||
if (values.size >= 6) {
|
||||
val next = MpvPlaybackState(values[0], values[1], values[2], values[3] != 0.0, values[4] != 0.0, values[5] != 0.0)
|
||||
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,
|
||||
// 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
|
||||
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()
|
||||
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() {
|
||||
runCatching {
|
||||
val encoded = withOpenHandle(::nativeTracks) ?: return
|
||||
@@ -149,16 +366,28 @@ class NativeMpvHost : LibmpvHost, AutoCloseable {
|
||||
|
||||
override fun close() {
|
||||
scope.cancel()
|
||||
lifecycle.withLock {
|
||||
if (closed) return@withLock
|
||||
val destroy = lifecycle.withLock {
|
||||
if (closed) return@withLock false
|
||||
closed = true
|
||||
onPlaybackStarted = null
|
||||
onStateChanged = null
|
||||
onExplicitStop = null
|
||||
nativeDestroy(nativeHandle)
|
||||
currentStreamHandle?.let(::pstrAndroidStreamRelease)
|
||||
currentStreamHandle = null
|
||||
onSkipPrevious = null
|
||||
onSkipNext = 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. */
|
||||
@@ -166,7 +395,6 @@ class NativeMpvHost : LibmpvHost, AutoCloseable {
|
||||
if (closed) null else block(nativeHandle)
|
||||
}
|
||||
|
||||
private external fun nativeCreate(): Long
|
||||
private external fun nativeDestroy(handle: Long)
|
||||
private external fun nativeAttachSurface(handle: Long, surface: Surface)
|
||||
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 nativeSeek(handle: Long, position: 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 nativeSelectTrack(handle: Long, audio: Boolean, track: Long)
|
||||
private external fun nativeStop(handle: Long)
|
||||
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 fun DoubleArray.generation(): Double = getOrElse(GENERATION) { -1.0 }
|
||||
|
||||
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 {
|
||||
System.loadLibrary("pstr_android")
|
||||
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.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.Icon
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
@@ -14,38 +19,99 @@ import android.media.session.MediaSession
|
||||
import android.media.session.PlaybackState
|
||||
import android.os.Binder
|
||||
import android.os.IBinder
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.narl.protonstream.MainActivity
|
||||
import io.narl.protonstream.R
|
||||
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. */
|
||||
class PlaybackService : Service() {
|
||||
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 audioManager: AudioManager
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
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 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()
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createChannel()
|
||||
ContextCompat.registerReceiver(
|
||||
this,
|
||||
becomingNoisy,
|
||||
IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY),
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
audioManager = getSystemService(AudioManager::class.java)
|
||||
host = NativeMpvHost().apply {
|
||||
host = NativeMpvHost.createOrNull()?.apply {
|
||||
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
|
||||
if (SettingsStore(this@PlaybackService).backgroundAudio) {
|
||||
cancelRetire()
|
||||
if (!foregroundPlayback && SettingsStore(this@PlaybackService).backgroundAudio) {
|
||||
// Convert the bound service into a started foreground
|
||||
// service only when the viewer opted to keep audio alive.
|
||||
startService(Intent(this@PlaybackService, PlaybackService::class.java))
|
||||
startForeground(NOTIFICATION_ID, notification(false))
|
||||
foregroundPlayback = true
|
||||
// Already-foreground is the ordinary case across an episode
|
||||
// transition, and re-entering costs a notification rebuild.
|
||||
foregroundPlayback = runCatching {
|
||||
startService(Intent(this@PlaybackService, PlaybackService::class.java))
|
||||
startForeground(NOTIFICATION_ID, notification(state.value, nowPlaying.value))
|
||||
}.isSuccess
|
||||
}
|
||||
mediaSession.isActive = true
|
||||
}
|
||||
@@ -53,80 +119,213 @@ class PlaybackService : Service() {
|
||||
onExplicitStop = { retirePlayback() }
|
||||
}
|
||||
mediaSession = MediaSession(this, "proton-stream").apply {
|
||||
setSessionActivity(openActivity())
|
||||
setCallback(object : MediaSession.Callback() {
|
||||
override fun onPlay() = host.setPaused(false)
|
||||
override fun onPause() = host.setPaused(true)
|
||||
override fun onSeekTo(pos: Long) = host.seek(pos / 1_000.0)
|
||||
override fun onStop() = host.stop()
|
||||
override fun onPlay() { host?.setPaused(false) }
|
||||
override fun onPause() { host?.setPaused(true) }
|
||||
override fun onSeekTo(pos: Long) { host?.seek(pos / 1_000.0) }
|
||||
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 onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_PLAY -> host.setPaused(false)
|
||||
ACTION_PAUSE -> host.setPaused(true)
|
||||
ACTION_STOP -> host.stop()
|
||||
ACTION_PLAY -> host?.setPaused(false)
|
||||
ACTION_PAUSE -> host?.setPaused(true)
|
||||
ACTION_PREVIOUS -> host?.onSkipPrevious?.invoke()
|
||||
ACTION_NEXT -> host?.onSkipNext?.invoke()
|
||||
ACTION_STOP -> host?.stop()
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
runCatching { unregisterReceiver(becomingNoisy) }
|
||||
audioFocus?.let(audioManager::abandonAudioFocusRequest)
|
||||
mediaSession.release()
|
||||
host.close()
|
||||
host?.close()
|
||||
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)
|
||||
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build(),
|
||||
)
|
||||
.setWillPauseWhenDucked(false)
|
||||
.setOnAudioFocusChangeListener { focus ->
|
||||
when (focus) {
|
||||
AudioManager.AUDIOFOCUS_LOSS, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> host.setPaused(true)
|
||||
AudioManager.AUDIOFOCUS_GAIN -> Unit
|
||||
AudioManager.AUDIOFOCUS_LOSS -> {
|
||||
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()
|
||||
audioFocus = request
|
||||
audioManager.requestAudioFocus(request)
|
||||
return audioManager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
}
|
||||
|
||||
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 {
|
||||
state.ended -> PlaybackState.STATE_STOPPED
|
||||
state.paused -> PlaybackState.STATE_PAUSED
|
||||
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(
|
||||
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)
|
||||
.build(),
|
||||
)
|
||||
mediaSession.setMetadata(
|
||||
MediaMetadata.Builder().putLong(MediaMetadata.METADATA_KEY_DURATION, (state.duration * 1_000).toLong()).build(),
|
||||
mediaSession.setMetadata(metadata(state, playing))
|
||||
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) {
|
||||
host.releaseEndedStream()
|
||||
retirePlayback()
|
||||
return
|
||||
}
|
||||
if (foregroundPlayback && notifiedPaused != state.paused) {
|
||||
notifiedPaused = state.paused
|
||||
getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(state.paused))
|
||||
if (foregroundPlayback && notified != shape) {
|
||||
notified = shape
|
||||
getSystemService(NotificationManager::class.java)
|
||||
.notify(NOTIFICATION_ID, notification(state, playing))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
cancelRetire()
|
||||
if (!sessionPlayback && !foregroundPlayback) return
|
||||
sessionPlayback = false
|
||||
notifiedPaused = null
|
||||
notified = null
|
||||
artwork = null
|
||||
mediaSession.isActive = false
|
||||
audioFocus?.let(audioManager::abandonAudioFocusRequest)
|
||||
audioFocus = null
|
||||
@@ -135,40 +334,98 @@ class PlaybackService : Service() {
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun notification(paused: Boolean): Notification {
|
||||
val open = PendingIntent.getActivity(
|
||||
this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
val toggleAction = if (paused) ACTION_PLAY else ACTION_PAUSE
|
||||
val toggle = PendingIntent.getService(
|
||||
this, 1, Intent(this, PlaybackService::class.java).setAction(toggleAction), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
val stop = PendingIntent.getService(
|
||||
this, 2, Intent(this, PlaybackService::class.java).setAction(ACTION_STOP), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
private fun openActivity(): PendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
private fun command(requestCode: Int, action: String): PendingIntent = PendingIntent.getService(
|
||||
this,
|
||||
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)
|
||||
.setSmallIcon(android.R.drawable.ic_media_play)
|
||||
.setContentTitle("proton-stream")
|
||||
.setContentText(if (paused) "Playback paused" else "Playing")
|
||||
.setContentIntent(open)
|
||||
.setSmallIcon(R.drawable.ic_notification_stream)
|
||||
.setContentTitle(playing?.title ?: "proton-stream")
|
||||
.setContentText(playing?.show ?: playing?.detail ?: if (paused) "Paused" else "Playing")
|
||||
.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)
|
||||
.addAction(Notification.Action.Builder(null, if (paused) "Play" else "Pause", toggle).build())
|
||||
.addAction(Notification.Action.Builder(null, "Stop", stop).build())
|
||||
.setStyle(Notification.MediaStyle().setMediaSession(mediaSession.sessionToken).setShowActionsInCompactView(0, 1))
|
||||
.apply { actions.forEach { addAction(it) } }
|
||||
.setStyle(style)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun createChannel() {
|
||||
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 {
|
||||
private const val CHANNEL_ID = "playback"
|
||||
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_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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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.TitleRecord
|
||||
import uniffi.pstr_android.OfflineRecord
|
||||
import uniffi.pstr_android.MatchSummary
|
||||
import uniffi.pstr_android.MetadataProvider
|
||||
import uniffi.pstr_android.MetadataSettingsRecord
|
||||
import uniffi.pstr_android.StorageUsageRecord
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
|
||||
data class AppUiState(
|
||||
val loading: Boolean = true,
|
||||
@@ -44,6 +47,7 @@ data class AppUiState(
|
||||
language = "en",
|
||||
ready = true,
|
||||
),
|
||||
val storage: StorageUsageRecord = StorageUsageRecord(0uL, 0uL, 0uL, 0uL),
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
@@ -55,6 +59,11 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
val state: StateFlow<AppUiState> = mutableState.asStateFlow()
|
||||
|
||||
init {
|
||||
if (!NativeRuntime.tlsReady) {
|
||||
mutableState.update {
|
||||
it.copy(message = "Certificate verification is unavailable; Proton requests will fail")
|
||||
}
|
||||
}
|
||||
reload()
|
||||
viewModelScope.launch {
|
||||
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?) {
|
||||
viewModelScope.launch {
|
||||
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) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
@@ -132,8 +200,10 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
engine.setMetadataApiKey(provider, apiKey)
|
||||
}
|
||||
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)
|
||||
reload()
|
||||
}
|
||||
@@ -141,6 +211,23 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
|
||||
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) {
|
||||
viewModelScope.launch {
|
||||
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() {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
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(
|
||||
engine.shares(),
|
||||
engine.library(mutableState.value.query.takeIf(String::isNotBlank)),
|
||||
engine.offlineFiles(),
|
||||
engine.metadataSettings(),
|
||||
engine.storageUsage(),
|
||||
)
|
||||
}
|
||||
}.onSuccess { result ->
|
||||
@@ -189,6 +356,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
titles = result.titles,
|
||||
offline = result.offline,
|
||||
metadataSettings = result.metadataSettings,
|
||||
storage = result.storage,
|
||||
) }
|
||||
}
|
||||
.onFailure { error ->
|
||||
@@ -215,6 +383,7 @@ private data class Reloaded(
|
||||
val titles: List<TitleRecord>,
|
||||
val offline: List<OfflineRecord>,
|
||||
val metadataSettings: MetadataSettingsRecord,
|
||||
val storage: StorageUsageRecord,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.LruCache
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
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.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URI
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
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
|
||||
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
|
||||
var image by remember(url) { mutableStateOf<ImageBitmap?>(null) }
|
||||
LaunchedEffect(url) {
|
||||
image = url?.let { 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 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()
|
||||
}
|
||||
}
|
||||
var image by remember(url, fallback) { mutableStateOf<ImageBitmap?>(null) }
|
||||
LaunchedEffect(url, fallback) {
|
||||
image = (loadArtwork(context, url) ?: loadThumbnail(fallback))?.asImageBitmap()
|
||||
}
|
||||
Box(modifier.background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center) {
|
||||
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_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. */
|
||||
private fun readBounded(input: InputStream): ByteArray {
|
||||
|
||||
@@ -1,24 +1,103 @@
|
||||
package io.narl.protonstream.ui.theme
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
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 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),
|
||||
onPrimary = Color(0xFF1E1E2E),
|
||||
secondary = Color(0xFFF5C2E7),
|
||||
background = Color(0xFF1E1E2E),
|
||||
surface = Color(0xFF181825),
|
||||
surfaceVariant = Color(0xFF313244),
|
||||
onBackground = Color(0xFFCDD6F4),
|
||||
onSurface = Color(0xFFCDD6F4),
|
||||
outline = Color(0xFF6C7086),
|
||||
error = Color(0xFFF38BA8),
|
||||
)
|
||||
/**
|
||||
* The palette in force, shared across every composition.
|
||||
*
|
||||
* The choice is stored by Rust and resolved by Rust — the same flavours, the
|
||||
* same accent pairings and the same contrast rule the desktop client uses — so
|
||||
* what lives here is only the resolved answer and a way to replace it when the
|
||||
* viewer picks something else.
|
||||
*/
|
||||
object AppearanceState {
|
||||
private val current = MutableStateFlow<PaletteRecord?>(null)
|
||||
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
|
||||
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
|
||||
@@ -6,10 +6,14 @@ activity = "1.11.0"
|
||||
lifecycle = "2.9.4"
|
||||
navigation = "2.9.5"
|
||||
work = "2.10.5"
|
||||
media3 = "1.8.0"
|
||||
adaptive = "1.1.0"
|
||||
jna = "5.17.0"
|
||||
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]
|
||||
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-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" }
|
||||
androidx-work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
|
||||
androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "media3" }
|
||||
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
|
||||
androidx-work-testing = { module = "androidx.work:work-testing", version.ref = "work" }
|
||||
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
|
||||
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
|
||||
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-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-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" }
|
||||
rustls-platform-verifier = { module = "rustls:rustls-platform-verifier", version = "0.1.1" }
|
||||
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]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-plugin" }
|
||||
@@ -29,6 +29,7 @@ thiserror.workspace = true
|
||||
uniffi.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
log = "0.4"
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
# Match rustls-platform-verifier's JNI types: Java objects from different
|
||||
@@ -36,4 +37,3 @@ serde_json.workspace = true
|
||||
jni = "0.21.1"
|
||||
rustls-platform-verifier = "0.6.2"
|
||||
android_logger = "0.14"
|
||||
log = "0.4"
|
||||
+1328
-153
File diff suppressed because it is too large.
Load diff
@@ -1622,7 +1622,7 @@ impl Engine {
|
||||
// Only for a title that matched: there is no id to
|
||||
// ask about otherwise.
|
||||
let episodes = match &record.metadata {
|
||||
Some(found) => episodes_of(&service, title, found).await,
|
||||
Some(found) => service.title_episodes(title, found).await,
|
||||
None => Vec::new(),
|
||||
};
|
||||
Ok((record.title_key.clone(), Some(record), episodes))
|
||||
@@ -1632,7 +1632,7 @@ impl Engine {
|
||||
Work::Episodes(title, found) => Ok((
|
||||
title.key.clone(),
|
||||
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);
|
||||
}
|
||||
|
||||
let episodes = episodes_of(&service, &title, &found).await;
|
||||
let episodes = service.title_episodes(&title, &found).await;
|
||||
if !episodes.is_empty() {
|
||||
let stored = engine.catalog.lock().set_episode_metadata(
|
||||
&title.key,
|
||||
@@ -1855,53 +1855,6 @@ enum Work {
|
||||
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.
|
||||
fn now() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
|
||||
+53
-354
@@ -23,11 +23,11 @@ use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{
|
||||
Color32, CornerRadius, Rect, Rgba, Stroke, SystemTheme, TextureHandle, TextureOptions, Theme,
|
||||
Color32, CornerRadius, Rect, Stroke, SystemTheme, TextureHandle, TextureOptions, Theme,
|
||||
ThemePreference, ViewportCommand, Visuals,
|
||||
};
|
||||
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
|
||||
/// everything together.
|
||||
@@ -91,11 +91,12 @@ fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Every colour the app draws with, resolved from a [`Flavor`] and an
|
||||
/// [`Accent`].
|
||||
/// Every colour the app draws with, as egui wants them.
|
||||
///
|
||||
/// `Copy`, and small, so reading it is a lock and a memcpy rather than anything
|
||||
/// a drawing loop has to think about.
|
||||
/// The *choice* of colours is `pstr_core::appearance` — one resolution shared
|
||||
/// 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)]
|
||||
pub struct Palette {
|
||||
/// Behind everything: the page.
|
||||
@@ -118,15 +119,54 @@ pub struct Palette {
|
||||
/// The accent taken down into the background, for a pressed control and for
|
||||
/// selection behind text.
|
||||
pub accent_dim: Color32,
|
||||
/// 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.
|
||||
/// Ink that stays readable on top of the accent.
|
||||
pub on_accent: Color32,
|
||||
pub danger: Color32,
|
||||
/// Whether this is a light theme.
|
||||
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.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Direction {
|
||||
@@ -437,353 +477,10 @@ fn ramp(ctx: &egui::Context, from: Color32, to: Color32, direction: Direction) -
|
||||
/// light, which is what this does.
|
||||
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)]
|
||||
mod tests {
|
||||
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]
|
||||
fn the_shipped_default_is_the_palette_that_was_here_before() {
|
||||
let resolved = Palette::resolve(Appearance::default());
|
||||
@@ -795,11 +492,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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 to = Color32::from_rgb(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));
|
||||
assert_ne!(mix(from, to, 0.5), from);
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,395 @@ pub fn save(dirs: &AppDirs, appearance: &Appearance) -> Result<()> {
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -209,4 +598,54 @@ mod tests {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -227,6 +227,16 @@ impl 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<()> {
|
||||
let version: i64 = self
|
||||
.conn
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
pub mod appearance;
|
||||
pub mod catalog;
|
||||
pub mod chapters;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod library;
|
||||
|
||||
@@ -18,6 +18,11 @@ use crate::error::Result;
|
||||
/// referenced here because `pstr-core` does not depend on the player.
|
||||
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.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
// 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
|
||||
/// away for someone who falls asleep to it.
|
||||
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 {
|
||||
@@ -54,6 +71,8 @@ impl Default for PlaybackPrefs {
|
||||
subtitle_language: None,
|
||||
subtitles: true,
|
||||
autoplay_next: true,
|
||||
auto_skip: false,
|
||||
speed: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,6 +87,10 @@ impl PlaybackPrefs {
|
||||
self.volume = 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.subtitle_language = self.subtitle_language.filter(|tag| !tag.trim().is_empty());
|
||||
self
|
||||
@@ -124,6 +147,8 @@ mod tests {
|
||||
subtitle_language: Some("eng".into()),
|
||||
subtitles: true,
|
||||
autoplay_next: false,
|
||||
auto_skip: true,
|
||||
speed: 1.5,
|
||||
};
|
||||
save(&dirs, &prefs).unwrap();
|
||||
assert_eq!(load(&dirs).unwrap(), prefs);
|
||||
|
||||
@@ -160,6 +160,49 @@ impl ShareStore {
|
||||
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.
|
||||
///
|
||||
/// 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
|
||||
/// unwatchable.
|
||||
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 failures = Vec::new();
|
||||
|
||||
for share in store.list()? {
|
||||
if let Some(client) = reusable.remove(&share.id) {
|
||||
clients.insert(share.id, client);
|
||||
continue;
|
||||
}
|
||||
match store.open(&share).await {
|
||||
Ok(client) => {
|
||||
clients.insert(share.id.clone(), client);
|
||||
@@ -238,6 +299,23 @@ impl SharedLibrary {
|
||||
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.
|
||||
pub fn client(&self, share_id: &str) -> Option<&ProtonDrivePublicLinkClient> {
|
||||
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
|
||||
/// would mix a secret into the config file.
|
||||
#[test]
|
||||
|
||||
@@ -223,6 +223,54 @@ impl MetadataService {
|
||||
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
|
||||
/// with several seasons has to be searched for once per season.
|
||||
pub fn splits_seasons(&self) -> bool {
|
||||
|
||||
@@ -9,6 +9,9 @@ rust-version.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[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
|
||||
tokio.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
@@ -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
|
||||
//! sub-property at a time — for the same reason: those are plain strings and
|
||||
//! 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;
|
||||
|
||||
/// 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)
|
||||
}
|
||||
pub use pstr_core::chapters::{
|
||||
Chapter, ChapterRole, chapter_at, chapter_end, credits_start, roles,
|
||||
};
|
||||
|
||||
/// Every chapter of the file mpv currently has open.
|
||||
pub(crate) fn read(mpv: &Mpv) -> Vec<Chapter> {
|
||||
@@ -293,211 +32,3 @@ pub(crate) fn read(mpv: &Mpv) -> Vec<Chapter> {
|
||||
})
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,10 @@ impl StreamSource {
|
||||
uid: uid.clone(),
|
||||
};
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,20 +259,12 @@ impl VideoStream {
|
||||
/// 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
|
||||
/// call that will never finish.
|
||||
fn cancel_read_ahead(&self) {
|
||||
let tasks = std::mem::take(&mut *self.inner.tasks());
|
||||
let mut cancelled = 0_u64;
|
||||
for task in tasks {
|
||||
if !task.is_finished() {
|
||||
task.abort();
|
||||
cancelled += 1;
|
||||
}
|
||||
}
|
||||
if cancelled > 0 {
|
||||
self.inner
|
||||
.readahead_cancelled
|
||||
.fetch_add(cancelled, Ordering::Relaxed);
|
||||
}
|
||||
///
|
||||
/// Public because a seek is not the only reason to stop speculating: a
|
||||
/// stream nobody is reading any more should not keep spending a phone's
|
||||
/// data plan, which is what [`crate::StreamSource::close`] uses this for.
|
||||
pub fn cancel_read_ahead(&self) {
|
||||
self.inner.cancel_read_ahead();
|
||||
}
|
||||
|
||||
/// Queue the next few blocks after `from`.
|
||||
@@ -300,10 +292,15 @@ impl VideoStream {
|
||||
};
|
||||
|
||||
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 _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");
|
||||
}
|
||||
});
|
||||
@@ -352,6 +349,39 @@ impl Inner {
|
||||
.lock()
|
||||
.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.
|
||||
@@ -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
|
||||
/// read-ahead away.
|
||||
#[tokio::test]
|
||||
|
||||
+26
-3
@@ -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
|
||||
root and `bash scripts/build-android.sh check`. Test phone and tablet layouts,
|
||||
rotation, process recreation, playback/background controls, Picture-in-Picture,
|
||||
download cancellation/resume, and an offline launch with networking disabled.
|
||||
root, then:
|
||||
|
||||
```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
|
||||
|
||||
|
||||
+875
-2
@@ -7,11 +7,884 @@ verified. Reference entries from PRs.
|
||||
|
||||
## Open
|
||||
|
||||
_None yet — the app is young enough that everything known is in
|
||||
`docs/DEVELOPMENT.md` as unbuilt rather than broken._
|
||||
All of B14–B33 came out of a static review of the Android client
|
||||
(`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
|
||||
|
||||
### 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
|
||||
|
||||
**Symptom.** On the player page the transport row — previous, ±seconds, play,
|
||||
|
||||
+108
@@ -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
|
||||
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
|
||||
|
||||
The streaming surface this app depends on is tested in `../proton-sdk-rs`:
|
||||
|
||||
Executable
+867
@@ -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)
|
||||
Executable
+46
@@ -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" "$@"
|
||||
@@ -7,11 +7,14 @@ android_dir="$repo_root/android"
|
||||
|
||||
usage() {
|
||||
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
|
||||
debug) tasks=(assembleDebug) ;;
|
||||
check) tasks=(lintDebug testDebugUnitTest) ;;
|
||||
check) tasks=(ktlintCheck lintDebug testDebugUnitTest) ;;
|
||||
release) tasks=(assembleRelease bundleRelease) ;;
|
||||
signed)
|
||||
required=(
|
||||
@@ -28,10 +31,42 @@ case "${1:-}" in
|
||||
done
|
||||
tasks=(assembleRelease bundleRelease)
|
||||
;;
|
||||
all) tasks=(lintDebug testDebugUnitTest assembleDebug assembleRelease bundleRelease) ;;
|
||||
all) tasks=(ktlintCheck lintDebug testDebugUnitTest assembleDebug assembleRelease bundleRelease) ;;
|
||||
*) usage >&2; exit 2 ;;
|
||||
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
|
||||
gradle=("$android_dir/gradlew")
|
||||
elif command -v gradle >/dev/null 2>&1; then
|
||||
|
||||
Reference in new issue
Block a user