diff --git a/Cargo.lock b/Cargo.lock index b1c1399..d0d3f72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 7e49d9e..f0b6d6a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/android/.editorconfig b/android/.editorconfig new file mode 100644 index 0000000..4740e98 --- /dev/null +++ b/android/.editorconfig @@ -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 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 890af25..753ee81 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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) } diff --git a/android/app/lint-baseline.xml b/android/app/lint-baseline.xml new file mode 100644 index 0000000..56f688f --- /dev/null +++ b/android/app/lint-baseline.xml @@ -0,0 +1,268 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/proguard-instrumentation.pro b/android/app/proguard-instrumentation.pro new file mode 100644 index 0000000..b3ebaa5 --- /dev/null +++ b/android/app/proguard-instrumentation.pro @@ -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** { *; } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index f26ea4f..ba490b0 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -7,3 +7,19 @@ -keepclasseswithmembernames class * { native ; } + +# 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 { + ; +} + +# 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 { *; } diff --git a/android/app/proguard-test-rules.pro b/android/app/proguard-test-rules.pro new file mode 100644 index 0000000..09898a9 --- /dev/null +++ b/android/app/proguard-test-rules.pro @@ -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 ; +} diff --git a/android/app/src/androidTest/java/io/narl/protonstream/download/DownloadCoordinatorInstrumentedTest.kt b/android/app/src/androidTest/java/io/narl/protonstream/download/DownloadCoordinatorInstrumentedTest.kt new file mode 100644 index 0000000..cf0e5cd --- /dev/null +++ b/android/app/src/androidTest/java/io/narl/protonstream/download/DownloadCoordinatorInstrumentedTest.kt @@ -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) + } +} diff --git a/android/app/src/androidTest/java/io/narl/protonstream/live/LiveShareTest.kt b/android/app/src/androidTest/java/io/narl/protonstream/live/LiveShareTest.kt new file mode 100644 index 0000000..9aeff87 --- /dev/null +++ b/android/app/src/androidTest/java/io/narl/protonstream/live/LiveShareTest.kt @@ -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 = + 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() + + 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) } + } + } +} diff --git a/android/app/src/androidTest/java/io/narl/protonstream/native/KeystoreSecretStoreTest.kt b/android/app/src/androidTest/java/io/narl/protonstream/native/KeystoreSecretStoreTest.kt new file mode 100644 index 0000000..23354ab --- /dev/null +++ b/android/app/src/androidTest/java/io/narl/protonstream/native/KeystoreSecretStoreTest.kt @@ -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 + } +} diff --git a/android/app/src/androidTest/java/io/narl/protonstream/native/UniFfiBoundaryTest.kt b/android/app/src/androidTest/java/io/narl/protonstream/native/UniFfiBoundaryTest.kt new file mode 100644 index 0000000..354a83b --- /dev/null +++ b/android/app/src/androidTest/java/io/narl/protonstream/native/UniFfiBoundaryTest.kt @@ -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() + 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") + } + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b997ab7..c49a54c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -29,7 +29,7 @@ +#include #include #include #include @@ -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(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(opaque); }); + c_boundary([=] { + std::unique_ptr cookie(static_cast(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; + /// 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(c); + const std::string_view input(text); + for (size_t i = 0; i < input.size();) { + const auto lead = static_cast(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(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(code); + continue; + } + if (code < 0x80) { + out += static_cast(code); + } else if (code < 0x800) { + out += static_cast(0xC0 | (code >> 6)); + out += static_cast(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(0xE0 | (code >> 12)); + out += static_cast(0x80 | ((code >> 6) & 0x3F)); + out += static_cast(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(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(event->data)); } else if (event->event_id == MPV_EVENT_PROPERTY_CHANGE) { update_property(*static_cast(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(property.data); - else if (!std::strcmp(property.name, "duration")) state_.duration = *static_cast(property.data); - else if (!std::strcmp(property.name, "volume")) state_.volume = *static_cast(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(property.data); else if (!std::strcmp(property.name, "pause")) state_.paused = *static_cast(property.data); else if (!std::strcmp(property.name, "mute")) state_.muted = *static_cast(property.data); + else if (state_.loading) return; + else if (!std::strcmp(property.name, "time-pos")) state_.position = *static_cast(property.data); + else if (!std::strcmp(property.name, "duration")) state_.duration = *static_cast(property.data); + else if (!std::strcmp(property.name, "video-params/dw")) state_.video_width = static_cast(*static_cast(property.data)); + else if (!std::strcmp(property.name, "video-params/dh")) state_.video_height = static_cast(*static_cast(property.data)); + else if (!std::strcmp(property.name, "paused-for-cache")) state_.buffering = *static_cast(property.data); + else if (!std::strcmp(property.name, "cache-buffering-state")) state_.cache_percent = static_cast(*static_cast(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(0, [] { auto player = std::make_unique(); return player->valid() ? reinterpret_cast(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(state.generation), + state.video_width, state.video_height, + static_cast(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(nullptr); - env->SetDoubleArrayRegion(result, 0, 6, values); + env->SetDoubleArrayRegion(result, 0, kFields, values); if (env->ExceptionCheck()) return static_cast(nullptr); return result; }); } +extern "C" JNIEXPORT jstring JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeTakeError(JNIEnv *env, jobject, jlong h) noexcept { + return c_boundary(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(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(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(handle)); }); } diff --git a/android/app/src/main/cpp/pstr_mpv.h b/android/app/src/main/cpp/pstr_mpv.h index 08f3833..5e1e955 100644 --- a/android/app/src/main/cpp/pstr_mpv.h +++ b/android/app/src/main/cpp/pstr_mpv.h @@ -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 } diff --git a/android/app/src/main/java/io/narl/protonstream/MainActivity.kt b/android/app/src/main/java/io/narl/protonstream/MainActivity.kt index 18d9c30..ec52ebe 100644 --- a/android/app/src/main/java/io/narl/protonstream/MainActivity.kt +++ b/android/app/src/main/java/io/narl/protonstream/MainActivity.kt @@ -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(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 + } } diff --git a/android/app/src/main/java/io/narl/protonstream/download/DownloadCoordinator.kt b/android/app/src/main/java/io/narl/protonstream/download/DownloadCoordinator.kt index ebf8105..34c46df 100644 --- a/android/app/src/main/java/io/narl/protonstream/download/DownloadCoordinator.kt +++ b/android/app/src/main/java/io/narl/protonstream/download/DownloadCoordinator.kt @@ -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() + } + + fun enqueue(context: Context, episodes: Iterable) { + 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() .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) { - 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 = diff --git a/android/app/src/main/java/io/narl/protonstream/download/DownloadStateStore.kt b/android/app/src/main/java/io/narl/protonstream/download/DownloadStateStore.kt index 7a31900..b309bf7 100644 --- a/android/app/src/main/java/io/narl/protonstream/download/DownloadStateStore.kt +++ b/android/app/src/main/java/io/narl/protonstream/download/DownloadStateStore.kt @@ -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) = 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() } } diff --git a/android/app/src/main/java/io/narl/protonstream/download/OfflineDownloadWorker.kt b/android/app/src/main/java/io/narl/protonstream/download/OfflineDownloadWorker.kt index f5cc1be..b0b3c0f 100644 --- a/android/app/src/main/java/io/narl/protonstream/download/OfflineDownloadWorker.kt +++ b/android/app/src/main/java/io/narl/protonstream/download/OfflineDownloadWorker.kt @@ -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 } } diff --git a/android/app/src/main/java/io/narl/protonstream/native/KeystoreSecretStore.kt b/android/app/src/main/java/io/narl/protonstream/native/KeystoreSecretStore.kt index 80ba313..ae56525 100644 --- a/android/app/src/main/java/io/narl/protonstream/native/KeystoreSecretStore.kt +++ b/android/app/src/main/java/io/narl/protonstream/native/KeystoreSecretStore.kt @@ -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 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 { diff --git a/android/app/src/main/java/io/narl/protonstream/native/NativeRuntime.kt b/android/app/src/main/java/io/narl/protonstream/native/NativeRuntime.kt index 3d544c8..463ef0b 100644 --- a/android/app/src/main/java/io/narl/protonstream/native/NativeRuntime.kt +++ b/android/app/src/main/java/io/narl/protonstream/native/NativeRuntime.kt @@ -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) { diff --git a/android/app/src/main/java/io/narl/protonstream/playback/LibmpvPlayerSurface.kt b/android/app/src/main/java/io/narl/protonstream/playback/LibmpvPlayerSurface.kt deleted file mode 100644 index ae383a6..0000000 --- a/android/app/src/main/java/io/narl/protonstream/playback/LibmpvPlayerSurface.kt +++ /dev/null @@ -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(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(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, - 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) -} diff --git a/android/app/src/main/java/io/narl/protonstream/playback/NativeMpvHost.kt b/android/app/src/main/java/io/narl/protonstream/playback/NativeMpvHost.kt index ce0675d..b8ccc74 100644 --- a/android/app/src/main/java/io/narl/protonstream/playback/NativeMpvHost.kt +++ b/android/app/src/main/java/io/narl/protonstream/playback/NativeMpvHost.kt @@ -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()) + private val mutableChapters = MutableStateFlow(ChapterPlan(emptyList(), null)) + private val mutableNowPlaying = MutableStateFlow(null) + private val mutableAdvancing = MutableStateFlow(false) + private val mutableProblem = MutableStateFlow(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? = 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 = mutableState.asStateFlow() val tracks: StateFlow> = mutableTracks.asStateFlow() + /** The open file's chapters, already resolved into openings and endings. */ + val chapters: StateFlow = mutableChapters.asStateFlow() + /** The episode the player has open, for every surface that has to name it. */ + val nowPlaying: StateFlow = 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 = 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 = 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 } } diff --git a/android/app/src/main/java/io/narl/protonstream/playback/PlaybackService.kt b/android/app/src/main/java/io/narl/protonstream/playback/PlaybackService.kt index 9bf36ad..2083dc3 100644 --- a/android/app/src/main/java/io/narl/protonstream/playback/PlaybackService.kt +++ b/android/app/src/main/java/io/narl/protonstream/playback/PlaybackService.kt @@ -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? = 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" } } diff --git a/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt b/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt new file mode 100644 index 0000000..cfc0305 --- /dev/null +++ b/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt @@ -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, + 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(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() } + 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, + tracks: List, + hasPrevious: Boolean, + hasNext: Boolean, + onPrevious: () -> Unit, + onNext: () -> Unit, + onRotate: () -> Unit, + landscapeLocked: Boolean, +) { + val scope = rememberCoroutineScope() + var choosing by remember { mutableStateOf(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(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(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, 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, + 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, + 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) +} diff --git a/android/app/src/main/java/io/narl/protonstream/playback/StreamSession.kt b/android/app/src/main/java/io/narl/protonstream/playback/StreamSession.kt new file mode 100644 index 0000000..5156ddc --- /dev/null +++ b/android/app/src/main/java/io/narl/protonstream/playback/StreamSession.kt @@ -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}" diff --git a/android/app/src/main/java/io/narl/protonstream/settings/SettingsStore.kt b/android/app/src/main/java/io/narl/protonstream/settings/SettingsStore.kt index 8c41cc1..d03aa46 100644 --- a/android/app/src/main/java/io/narl/protonstream/settings/SettingsStore.kt +++ b/android/app/src/main/java/io/narl/protonstream/settings/SettingsStore.kt @@ -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) diff --git a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt index 475232a..e17da8a 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt @@ -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 = 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, val offline: List, val metadataSettings: MetadataSettingsRecord, + val storage: StorageUsageRecord, ) private fun WorkManager.workInfosByTagFlow(tag: String) = callbackFlow { diff --git a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt index a5dcaa3..857e5f2 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt @@ -1,10 +1,11 @@ package io.narl.protonstream.ui -import android.content.res.Configuration +import android.content.Intent import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent -import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.border +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -20,13 +21,14 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.lazy.items import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card @@ -48,6 +50,8 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.outlined.CheckCircle import androidx.compose.material.icons.filled.VideoLibrary import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Download @@ -55,26 +59,30 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold 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.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -84,6 +92,7 @@ import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.SecureFlagPolicy import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel import androidx.work.WorkManager import io.narl.protonstream.settings.SettingsStore @@ -94,14 +103,36 @@ import io.narl.protonstream.download.DownloadCoordinator import io.narl.protonstream.download.DownloadStateStore import io.narl.protonstream.download.RetainedDownload import io.narl.protonstream.native.NativeRuntime +import io.narl.protonstream.ui.theme.AppearanceState import uniffi.pstr_android.ShareRecord import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.TrackPreferencesRecord import uniffi.pstr_android.EpisodeRecord +import uniffi.pstr_android.SeasonRecord import uniffi.pstr_android.MatchRecord import uniffi.pstr_android.MetadataProvider +import uniffi.pstr_android.FlavorChoice +import uniffi.pstr_android.AppearanceRecord +import uniffi.pstr_android.AccentChoice +import uniffi.pstr_android.PlaybackPrefsRecord +import io.narl.protonstream.playback.LibmpvHost import io.narl.protonstream.playback.NativeMpvHost -import io.narl.protonstream.playback.LibmpvPlayerSurface +import io.narl.protonstream.playback.PlayerScreen + +/** + * How many shows the continue-watching shelf holds. + * + * A shelf is a shortcut, not a second library: past about this many it is + * quicker to search for the thing than to scroll the shelf looking for it. + */ +private const val CONTINUE_WATCHING_MAX = 12 + +/** What the player was asked to open: a title's episodes, and which one. */ +private data class PlayRequest( + val title: TitleRecord, + val episodes: List, + val index: Int, +) private enum class Destination(val label: String, val icon: ImageVector) { Library("Library", Icons.Default.VideoLibrary), @@ -113,7 +144,8 @@ private enum class Destination(val label: String, val icon: ImageVector) { @OptIn(ExperimentalMaterial3Api::class) @Composable fun ProtonStreamApp( - playerHost: NativeMpvHost? = null, + playerHost: LibmpvHost? = null, + inPictureInPicture: Boolean = false, model: AppViewModel = viewModel( factory = AppViewModel.Factory( LocalContext.current, @@ -123,14 +155,27 @@ fun ProtonStreamApp( ) { val state by model.state.collectAsState() val snackbars = remember { SnackbarHostState() } - var destination by remember { mutableStateOf(Destination.Library) } - var selectedTitle by remember { mutableStateOf(null) } + // Keys, not records: what has to survive process recreation is *which* title + // and which episode, and a `TitleRecord` is neither parcelable nor still + // current after the library reloads. Everything else is derived, so a reload + // that renames a season updates the open screen rather than pinning a stale + // copy of it. + var destination by rememberSaveable { mutableStateOf(Destination.Library) } + var selectedTitleKey by rememberSaveable { mutableStateOf(null) } + var playingTitleKey by rememberSaveable { mutableStateOf(null) } + var playingIndex by rememberSaveable { mutableIntStateOf(0) } + // Playing, but not on screen: the episode keeps going and the mini transport + // is what says so. Saved, because a rotation must not throw the viewer back + // into the video they had just left. + var playerMinimized by rememberSaveable { mutableStateOf(false) } - LaunchedEffect(state.titles, selectedTitle?.key) { - selectedTitle?.let { selected -> - selectedTitle = state.titles.firstOrNull { it.key == selected.key } ?: selected + val selectedTitle = state.titles.firstOrNull { it.key == selectedTitleKey } + val playing = playingTitleKey + ?.let { key -> state.titles.firstOrNull { it.key == key } } + ?.let { title -> + val episodes = title.seasons.flatMap(SeasonRecord::episodes) + PlayRequest(title, episodes, playingIndex.coerceIn(0, (episodes.size - 1).coerceAtLeast(0))) } - } LaunchedEffect(state.message) { state.message?.let { @@ -143,7 +188,7 @@ fun ProtonStreamApp( Destination.entries.forEach { item -> item( selected = destination == item, - onClick = { destination = item; selectedTitle = null }, + onClick = { destination = item; selectedTitleKey = null }, icon = { Icon(item.icon, contentDescription = item.label) }, label = { Text(item.label) }, ) @@ -170,21 +215,52 @@ fun ProtonStreamApp( ) }, ) { padding -> + // Back from a secondary tab returns to the library rather than + // leaving the app; the library's own back is the system's, which is + // what a top-level destination should do. + BackHandler(enabled = destination != Destination.Library) { + destination = Destination.Library + } + val minimizedHost = (playerHost as? NativeMpvHost)?.takeIf { playing != null && playerMinimized } + Box(Modifier.fillMaxSize()) { AnimatedContent(destination, label = "primary navigation") { target -> when (target) { Destination.Library -> if (selectedTitle == null) { - LibraryScreen(state, model::search, { selectedTitle = it }, padding) + LibraryScreen( + state, + model::search, + { title, index -> + playingTitleKey = title.key + playingIndex = index + playerMinimized = false + }, + { selectedTitleKey = it.key }, + padding, + ) } else { TitleScreen( - selectedTitle!!, - playerHost, - { selectedTitle = null }, + selectedTitle, + playerHost != null, + { _, index -> + playingTitleKey = selectedTitle.key + playingIndex = index + playerMinimized = false + }, + { selectedTitleKey = null }, model::reportError, model::reloadAfterMetadataChange, + model::setWatched, padding, ) } - Destination.Shares -> SharesScreen(state.shares, model::addShare, model::removeShare, padding) + Destination.Shares -> SharesScreen( + state.shares, + model::addShare, + model::repairShare, + model::refreshShare, + model::removeShare, + padding, + ) Destination.Downloads -> DownloadsScreen( state, model::removeOffline, @@ -193,21 +269,126 @@ fun ProtonStreamApp( model::deletePartial, padding, ) - Destination.Settings -> SettingsScreen(state, model::saveMetadataSettings, padding) + Destination.Settings -> SettingsScreen( + state, + model::saveMetadataSettings, + { model.matchTitles(force = true) }, + model::clearBlockCache, + model::removeAllOffline, + padding, + ) } } + minimizedHost?.let { core -> + MiniTransport( + host = core, + onRestore = { playerMinimized = false }, + onClose = { + playingTitleKey = null + playerMinimized = false + model.reloadAfterMetadataChange() + }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = padding.calculateBottomPadding() + 8.dp), + ) + } + } } } + // Drawn over the scaffold rather than instead of it. The player owns the + // whole window while it is up — it is a screen, not a dialog over one, which + // is what lets it draw into the cutout and hand the right window to + // Picture-in-Picture — but the page behind it stays composed, so leaving the + // player finds the title page where it was rather than rebuilt from the top. + playing?.let { request -> + PlayerScreen( + title = request.title, + episodes = request.episodes, + index = request.index, + host = playerHost, + inPictureInPicture = inPictureInPicture, + minimized = playerMinimized, + onIndexChange = { playingIndex = it }, + onSaveProgress = model::saveProgress, + onMinimize = { playerMinimized = true }, + onClose = { + playingTitleKey = null + playerMinimized = false + model.reloadAfterMetadataChange() + }, + ) + } } +/** + * The bar that says playback is still going, and gets back to it. + * + * Leaving the player does not stop it — the episode keeps playing, which is the + * whole point of the background-audio setting — so there has to be something on + * screen that says so and takes one tap to return to. Without it, the only way + * back to a playing episode is to find it in the library again. + */ +@Composable +private fun MiniTransport( + host: NativeMpvHost, + onRestore: () -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + val state by host.state.collectAsState() + val playing by host.nowPlaying.collectAsState() + val episode = playing ?: return + Card(modifier.fillMaxWidth().padding(horizontal = 8.dp).clickable(onClick = onRestore)) { + Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) { + RemoteArtwork( + episode.artworkUrl, + episode.show ?: episode.title, + Modifier.width(64.dp).aspectRatio(16f / 9f).clip(MaterialTheme.shapes.small), + fallback = episode.artworkFile, + ) + Column(Modifier.weight(1f).padding(horizontal = 12.dp)) { + Text(episode.title, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + episode.show?.let { + Text(it, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + IconButton(onClick = { host.setPaused(!state.paused) }) { + Icon( + if (state.paused) Icons.Default.PlayArrow else Icons.Default.Pause, + contentDescription = if (state.paused) "Play" else "Pause", + ) + } + IconButton(onClick = onClose) { + Icon(Icons.Default.Close, contentDescription = "Stop playback") + } + } + } +} + +/** One row of the continue-watching shelf: which episode, and where it sits. */ +private data class Resumable(val title: TitleRecord, val episode: EpisodeRecord, val index: Int) + @Composable private fun LibraryScreen( state: AppUiState, onSearch: (String) -> Unit, + onResume: (TitleRecord, Int) -> Unit, onTitle: (TitleRecord) -> Unit, padding: PaddingValues, ) { + // Most recently played first, one episode per show: a shelf that lists four + // episodes of the same series is a shelf with room for nothing else. + val resumable = remember(state.titles) { + state.titles.mapNotNull { title -> + val playlist = title.seasons.flatMap(SeasonRecord::episodes) + playlist.withIndex() + .filter { it.value.resumeAt != null && !it.value.watched } + .maxByOrNull { it.value.lastPlayed } + ?.let { Resumable(title, it.value, it.index) } + }.sortedByDescending { it.episode.lastPlayed }.take(CONTINUE_WATCHING_MAX) + } Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) { OutlinedTextField( value = state.query, @@ -218,6 +399,49 @@ private fun LibraryScreen( leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) } ) Spacer(Modifier.height(16.dp)) + // Above the grid, and only when there is something in it: what a viewer + // opening the app wants is almost always the thing they were part way + // through, and finding it in an alphabetical grid is the long way round. + if (!state.loading && state.query.isBlank() && resumable.isNotEmpty()) { + Text("Continue watching", style = MaterialTheme.typography.titleMedium) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(vertical = 12.dp), + ) { + items(resumable, key = { "${it.episode.shareId}/${it.episode.linkId}" }) { entry -> + Card( + Modifier.width(200.dp).clickable { onResume(entry.title, entry.index) }, + ) { + RemoteArtwork( + entry.episode.stillUrl ?: entry.title.backdropUrl, + entry.title.canonicalName ?: entry.title.name, + Modifier.fillMaxWidth().aspectRatio(16f / 9f), + fallback = entry.episode.thumbnailSource, + ) + Column(Modifier.padding(10.dp)) { + Text( + entry.title.canonicalName ?: entry.title.name, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + entry.episode.label, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + entry.episode.progress?.let { progress -> + LinearProgressIndicator( + progress = { progress.toFloat().coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + } + } + } + } + } + } if (state.loading) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } } else if (state.titles.isEmpty()) { @@ -235,6 +459,7 @@ private fun LibraryScreen( title.backdropUrl ?: title.posterUrl, title.canonicalName ?: title.name, Modifier.fillMaxWidth().height(210.dp), + fallback = title.thumbnailSource, ) Column(Modifier.padding(12.dp)) { Text(title.name, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis) @@ -253,10 +478,12 @@ private fun LibraryScreen( @Composable private fun TitleScreen( title: TitleRecord, - playerHost: NativeMpvHost?, + playerReady: Boolean, + onPlay: (List, Int) -> Unit, onBack: () -> Unit, onPreferenceError: (Throwable) -> Unit, onMetadataChanged: () -> Unit, + onSetWatched: (EpisodeRecord, Boolean) -> Unit, padding: PaddingValues, ) { val context = LocalContext.current @@ -264,109 +491,57 @@ private fun TitleScreen( var audioLanguage by remember(title.key) { mutableStateOf("") } var subtitleLanguage by remember(title.key) { mutableStateOf("") } var subtitlesEnabled by remember(title.key) { mutableStateOf(false) } - var playing by remember(title.key) { mutableStateOf(null) } var showMatch by remember(title.key) { mutableStateOf(false) } var expandedSeasons by remember(title.key) { mutableStateOf(setOf(title.seasons.firstOrNull()?.label)) } - - BackHandler(enabled = playing == null, onBack = onBack) - + + BackHandler(onBack = onBack) + LaunchedEffect(title.key) { runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().titleTrackPreferences(title.key) } }.onSuccess { preferences -> - audioLanguage = preferences.audioLanguage.orEmpty() - subtitleLanguage = preferences.subtitleLanguage.orEmpty() - subtitlesEnabled = preferences.subtitles + // Absent means this title has never been given a choice; the fields + // are then empty and the global preference is what plays. + audioLanguage = preferences?.audioLanguage.orEmpty() + subtitleLanguage = preferences?.subtitleLanguage.orEmpty() + subtitlesEnabled = preferences?.subtitles ?: true }.onFailure(onPreferenceError) } - if (playing != null) { - androidx.compose.ui.window.Dialog( - onDismissRequest = { - playerHost?.stop() - playing = null - }, - properties = androidx.compose.ui.window.DialogProperties( - usePlatformDefaultWidth = false, - decorFitsSystemWindows = false - ) - ) { - val configuration = LocalConfiguration.current - val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - var isFullscreen by remember { mutableStateOf(isLandscape) } - val activity = context as? android.app.Activity - - LaunchedEffect(isLandscape, isFullscreen) { - val window = activity?.window ?: return@LaunchedEffect - val controller = androidx.core.view.WindowInsetsControllerCompat(window, window.decorView) - if (isLandscape || isFullscreen) { - controller.hide(androidx.core.view.WindowInsetsCompat.Type.systemBars()) - controller.systemBarsBehavior = androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - if (isFullscreen && !isLandscape) { - activity.requestedOrientation = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - } - } else { - controller.show(androidx.core.view.WindowInsetsCompat.Type.systemBars()) - activity.requestedOrientation = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED - } - } - - DisposableEffect(Unit) { - onDispose { - val window = activity?.window - if (window != null) { - val controller = androidx.core.view.WindowInsetsControllerCompat(window, window.decorView) - controller.show(androidx.core.view.WindowInsetsCompat.Type.systemBars()) - } - activity?.requestedOrientation = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED - } - } - - BackHandler { - if (isFullscreen && !isLandscape) { - isFullscreen = false - } else { - playerHost?.stop() - playing = null - } - } - - Box(Modifier.fillMaxSize().background(Color.Black)) { - LibmpvPlayerSurface( - title.key, - playing!!, - playerHost, - isFullscreen = isLandscape || isFullscreen, - onToggleFullscreen = { isFullscreen = !isFullscreen } - ) - if (!isLandscape && !isFullscreen) { - IconButton( - onClick = { playerHost?.stop(); playing = null }, - Modifier.padding(padding).padding(16.dp).background(Color.Black.copy(alpha = 0.5f), androidx.compose.foundation.shape.CircleShape) - ) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close player", tint = Color.White) - } - } - } - } - return + // Display order across seasons: what previous/next and autoplay walk, and + // the same order the desktop client uses. + val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) } + // Where a press on Play lands. Part-watched wins over unwatched, most + // recently played first, which is the order the desktop client resumes in. + val nextUp = remember(playlist) { + val resumable = playlist.withIndex().filter { it.value.resumeAt != null } + resumable.maxByOrNull { it.value.lastPlayed }?.index + ?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 } + ?: 0 } + // Live download state per episode, so a row can show progress and be paused + // without a trip to the Downloads tab. + val work by WorkManager.getInstance(context) + .getWorkInfosByTagLiveData(DownloadCoordinator.TAG).observeAsState(emptyList()) + val downloads = remember(work) { DownloadStateStore(context).records() } + LazyColumn( modifier = Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { item { - TextButton(onClick = onBack) { + TextButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) Spacer(Modifier.width(8.dp)) - Text("Back to library") + Text("Back to library") } RemoteArtwork( title.backdropUrl ?: title.posterUrl, title.canonicalName ?: title.name, Modifier.fillMaxWidth().aspectRatio(16f / 9f).padding(top = 12.dp), + fallback = title.thumbnailSource, ) Text(title.canonicalName ?: title.name, style = MaterialTheme.typography.headlineMedium, modifier = Modifier.padding(top = 12.dp)) title.originalName?.takeIf { it != title.canonicalName }?.let { @@ -383,16 +558,57 @@ private fun TitleScreen( ) title.overview?.let { Text(it, modifier = Modifier.padding(top = 10.dp)) } Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 12.dp)) { - Button(onClick = { DownloadCoordinator.enqueue(context, title.seasons.flatMap { it.episodes }) }) { + // What a press on the poster should play: whatever was left + // part-watched, else the first unwatched episode, else the + // first — the desktop client's `next_up`. + Button(onClick = { onPlay(playlist, nextUp) }, enabled = playerReady && playlist.isNotEmpty()) { + Text( + when { + !playerReady -> "Player loading…" + playlist.getOrNull(nextUp)?.resumeAt != null -> "Resume" + title.watchedCount > 0uL -> "Continue" + else -> "Play" + }, + ) + } + // Only where it would do something different: an unstarted show + // already starts at the beginning. + if (playlist.getOrNull(nextUp)?.resumeAt != null || title.watchedCount > 0uL) { + FilledTonalButton( + onClick = { onPlay(playlist, 0) }, + enabled = playerReady && playlist.isNotEmpty(), + ) { Text("Start over") } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 8.dp)) { + FilledTonalButton(onClick = { DownloadCoordinator.enqueue(context, playlist) }) { Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) Text("Download show") } - FilledTonalButton(onClick = { showMatch = true }) { + FilledTonalButton(onClick = { showMatch = true }) { Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) - Text("Change match") + Text("Change match") } + // The provider's own page for this title: where a viewer goes to + // check that the thing the app matched is the thing they have. + title.externalUrl?.let { url -> + TextButton(onClick = { + runCatching { + context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) + }.onFailure(onPreferenceError) + }) { Text("More on ${title.metadataProvider?.displayName() ?: "the provider"}") } + } + } + // Said plainly, because it changes what a re-match will do: a + // hand-picked match is not overwritten by an automatic pass. + if (title.manualMatch) { + Text( + "Matched by hand", + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(top = 8.dp), + ) } Text("${title.watchedCount} of ${title.episodeCount} watched", Modifier.padding(vertical = 12.dp)) Text("Preferred tracks", style = MaterialTheme.typography.titleMedium) @@ -461,21 +677,18 @@ private fun TitleScreen( } if (isExpanded) { items(season.episodes, key = { it.linkId }) { episode -> - Card(Modifier.fillMaxWidth()) { - Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { - Button(onClick = { playing = episode }, enabled = playerHost != null) { - Text(if (playerHost == null) "Player loading…" else "Play") - } - Column(Modifier.weight(1f).padding(horizontal = 12.dp)) { - Text(episode.label, fontWeight = FontWeight.SemiBold) - Text(episode.detail, style = MaterialTheme.typography.bodySmall, maxLines = 1) - } - FilledTonalButton( - onClick = { DownloadCoordinator.enqueue(context, episode) }, - enabled = !episode.offline, - ) { Text(if (episode.offline) "Offline" else "Download") } - } - } + EpisodeRow( + episode = episode, + download = downloads.firstOrNull { + it.shareId == episode.shareId && it.linkId == episode.linkId + }, + playerReady = playerReady, + onPlay = { onPlay(playlist, playlist.indexOfFirst { it.linkId == episode.linkId }) }, + onDownload = { DownloadCoordinator.enqueue(context, episode) }, + onPause = { DownloadCoordinator.pause(context, it) }, + onResume = { DownloadCoordinator.resume(context, it) }, + onSetWatched = { onSetWatched(episode, it) }, + ) } } } @@ -490,6 +703,110 @@ private fun TitleScreen( } } +/** + * One episode: play it, see where it was left, and drive its download. + * + * A row carries the things a viewer acts on — resume, mark seen, download, + * pause — so they are controls, not a menu: the download button becomes the + * progress readout while it runs, which is the same space either way. + */ +@Composable +private fun EpisodeRow( + episode: EpisodeRecord, + download: RetainedDownload?, + playerReady: Boolean, + onPlay: () -> Unit, + onDownload: () -> Unit, + onPause: (RetainedDownload) -> Unit, + onResume: (RetainedDownload) -> Unit, + onSetWatched: (Boolean) -> Unit, +) { + val running = download?.status == RetainedDownload.STATUS_RUNNING || + download?.status == RetainedDownload.STATUS_QUEUED + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + // The still, and the largest target on the row for the thing the + // row is for. With metadata off the provider has no still, and + // Proton's own thumbnail is a frame of this very episode. + RemoteArtwork( + episode.stillUrl, + episode.providerName ?: episode.label, + Modifier + .width(96.dp) + .aspectRatio(16f / 9f) + .clip(MaterialTheme.shapes.small) + .clickable(enabled = playerReady, onClick = onPlay), + fallback = episode.thumbnailSource, + ) + Spacer(Modifier.width(12.dp)) + Button(onClick = onPlay, enabled = playerReady) { + Text( + when { + !playerReady -> "Player loading…" + episode.watched -> "Replay" + episode.resumeAt != null -> "Resume" + else -> "Play" + }, + ) + } + Column(Modifier.weight(1f).padding(horizontal = 12.dp)) { + Text(episode.label, fontWeight = FontWeight.SemiBold) + // The provider's name for the episode when there is one: + // "The Cave of Skulls" says more than the filename it was + // parsed out of. The filename detail stays as the fallback. + Text( + episode.providerName ?: episode.detail, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + ) + } + // Watched is a judgement the viewer is allowed to overrule: a + // half-watched episode they are done with, or one the 90 % rule + // marked seen because they sat through the credits. + IconButton(onClick = { onSetWatched(!episode.watched) }) { + Icon( + if (episode.watched) Icons.Default.CheckCircle else Icons.Outlined.CheckCircle, + contentDescription = if (episode.watched) "Mark unwatched" else "Mark watched", + tint = if (episode.watched) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline + }, + ) + } + when { + episode.offline -> Text("Offline", style = MaterialTheme.typography.labelMedium) + running -> FilledTonalButton(onClick = { onPause(download!!) }) { Text("Pause") } + download != null -> FilledTonalButton(onClick = { onResume(download) }) { Text("Resume") } + else -> FilledTonalButton(onClick = onDownload) { + Icon(Icons.Default.Download, contentDescription = "Download", modifier = Modifier.size(18.dp)) + } + } + } + // Where the episode was left, under the row it belongs to. + episode.progress?.takeIf { !episode.watched }?.let { progress -> + LinearProgressIndicator( + progress = { progress.toFloat().coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + } + download?.takeIf { it.total > 0L && !episode.offline }?.let { active -> + LinearProgressIndicator( + progress = { (active.downloaded.toFloat() / active.total).coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + color = MaterialTheme.colorScheme.tertiary, + ) + Text( + "${active.status.replaceFirstChar { it.uppercase() }} · " + + "${formatBytes(active.downloaded.toULong())} of ${formatBytes(active.total.toULong())}", + style = MaterialTheme.typography.bodySmall, + ) + } + } + } +} + @Composable private fun ChangeMatchDialog( title: TitleRecord, @@ -554,10 +871,13 @@ private fun ChangeMatchDialog( private fun SharesScreen( shares: List, onAdd: (String, String, String?) -> Unit, + onRepair: (String, String, String?) -> Unit, + onRefresh: (String) -> Unit, onRemove: (String) -> Unit, padding: PaddingValues, ) { var showAdd by remember { mutableStateOf(false) } + var repairing by remember { mutableStateOf(null) } Column(Modifier.fillMaxSize().padding(padding).padding(16.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Text("Proton Drive public links", style = MaterialTheme.typography.titleLarge) @@ -571,6 +891,17 @@ private fun SharesScreen( Text(share.name, fontWeight = FontWeight.SemiBold) Text(if (share.hasCustomPassword) "Custom password stored securely" else "Public link") } + // One share, not the library: a link that has just had + // files added to it should not cost a walk of every + // other one, and a link that has expired should not stop + // the ones that still work from being refreshed. + IconButton(onClick = { onRefresh(share.id) }) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh this share") + } + // Not a remove: a share whose secret has become + // unreadable cannot be removed either, since removal + // deletes a secret the store can no longer touch. + TextButton(onClick = { repairing = share }) { Text("Re-enter link") } FilledTonalButton(onClick = { onRemove(share.id) }) { Text("Remove") } } } @@ -578,6 +909,68 @@ private fun SharesScreen( } } if (showAdd) AddShareDialog(onDismiss = { showAdd = false }, onAdd = onAdd) + repairing?.let { share -> + RepairShareDialog( + share = share, + onDismiss = { repairing = null }, + onRepair = { url, password -> onRepair(share.id, url, password) }, + ) + } +} + +/** + * Re-enter the link for a share the app can no longer decrypt its secret for. + * + * It has to be the *same* link — a different token is a different share, and + * repointing this one at it would leave every catalog row and every offline file + * describing something that is not there. Rust enforces that; this only says so. + */ +@Composable +private fun RepairShareDialog( + share: ShareRecord, + onDismiss: () -> Unit, + onRepair: (String, String?) -> Unit, +) { + var url by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), + title = { Text("Re-enter link for ${share.name}") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "The stored credentials for this share cannot be read — usually after a " + + "screen-lock change or a device restore. Entering the same link again " + + "restores access without losing the library or anything downloaded.", + style = MaterialTheme.typography.bodySmall, + ) + OutlinedTextField( + url, + { url = it }, + label = { Text("Public share URL") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + ) + OutlinedTextField( + password, + { password = it }, + label = { Text("Custom password (optional)") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + ) + } + }, + confirmButton = { + Button( + onClick = { onRepair(url.trim(), password); onDismiss() }, + enabled = url.isNotBlank(), + ) { Text("Restore") } + }, + dismissButton = { FilledTonalButton(onClick = onDismiss) { Text("Cancel") } }, + ) } @Composable @@ -638,29 +1031,59 @@ private fun DownloadsScreen( // Reading retained metadata on every WorkInfo transition also hydrates // paused/failed/cancelled entries after WorkManager history is pruned. val retained = remember(downloads) { DownloadStateStore(context).records() } - Column(Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState())) { - Text("Offline downloads", style = MaterialTheme.typography.titleLarge) - Spacer(Modifier.height(16.dp)) - if (retained.isEmpty() && state.offline.isEmpty()) { - EmptyState("No downloads", "Episodes, seasons, and shows saved offline appear here.") + // Which show each saved file is from. The offline record knows its episode + // but not its show, and the library is the only thing that does. + val groups = remember(state.offline, state.titles) { + val shows = state.titles.flatMap { title -> + title.seasons.flatMap(SeasonRecord::episodes).map { + "${it.shareId}/${it.linkId}" to (title.canonicalName ?: title.name) + } + }.toMap() + state.offline + .groupBy { shows["${it.shareId}/${it.linkId}"] ?: "Not in the library" } + .toSortedMap() + } + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp), + contentPadding = PaddingValues(bottom = 16.dp), + ) { + item { + Text("Offline downloads", style = MaterialTheme.typography.titleLarge) + Spacer(Modifier.height(16.dp)) } - state.offline.forEach { file -> - val episode = file.episode - Card(Modifier.fillMaxWidth().padding(bottom = 8.dp)) { - Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f)) { - Text(episode?.label ?: file.linkId, fontWeight = FontWeight.SemiBold) - Text(formatBytes(file.size)) - } - FilledTonalButton(onClick = { onRemove(file) }) { - Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("Delete") + if (retained.isEmpty() && state.offline.isEmpty()) { + item { EmptyState("No downloads", "Episodes, seasons, and shows saved offline appear here.") } + } + // Under the show they belong to, as on desktop: a saved season is + // fourteen rows, and fourteen rows of "Episode 3" name nothing. + groups.forEach { (show, files) -> + item(key = "group/$show") { + Text(show, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 4.dp)) + Text( + "${files.size} ${if (files.size == 1) "episode" else "episodes"} · " + + formatBytes(files.sumOf { it.size }), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + items(files, key = { "${it.shareId}/${it.linkId}" }) { file -> + val episode = file.episode + Card(Modifier.fillMaxWidth().padding(bottom = 8.dp)) { + Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(episode?.label ?: file.linkId, fontWeight = FontWeight.SemiBold) + Text(formatBytes(file.size)) + } + FilledTonalButton(onClick = { onRemove(file) }) { + Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Delete") + } } } } } - retained.forEach { download -> + items(retained, key = { "${it.shareId}/${it.linkId}" }) { download -> val progress = if (download.total > 0L) download.downloaded.toFloat() / download.total else 0f Card(Modifier.fillMaxWidth().padding(bottom = 8.dp)) { Column(Modifier.padding(16.dp)) { @@ -697,17 +1120,43 @@ private fun DownloadsScreen( private fun SettingsScreen( state: AppUiState, onSaveMetadata: (Boolean, MetadataProvider, String, String) -> Unit, + onMatchAgain: () -> Unit, + onClearCache: () -> Unit, + onRemoveAllOffline: () -> Unit, padding: PaddingValues, ) { val context = LocalContext.current val settings = remember { SettingsStore(context) } var wifiOnly by remember { mutableStateOf(settings.wifiOnly) } var backgroundAudio by remember { mutableStateOf(settings.backgroundAudio) } + // Playback preferences are the shared store's, not this app's: they are the + // same file the desktop client reads, so a language chosen on one is the + // language the other starts in. + var prefs by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + fun update(change: (PlaybackPrefsRecord) -> PlaybackPrefsRecord) { + val next = change(prefs ?: return) + prefs = next + scope.launch(Dispatchers.IO) { + runCatching { NativeRuntime.engine().setPlaybackPrefs(next) } + } + } + LaunchedEffect(Unit) { + runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().playbackPrefs() } } + .onSuccess { prefs = it } + } + var confirmDelete by remember { mutableStateOf(false) } var showMetadata by remember { mutableStateOf(false) } var legalDocument by remember { mutableStateOf(null) } Column(Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState())) { Text("Settings", style = MaterialTheme.typography.titleLarge) - SettingToggle("Download on Wi-Fi only", wifiOnly) { wifiOnly = it; settings.wifiOnly = it } + SettingToggle("Download on Wi-Fi only", wifiOnly) { + wifiOnly = it + settings.wifiOnly = it + // Constraints are baked in at enqueue time, so a queue that already + // exists keeps the policy it was queued under until it is re-issued. + DownloadCoordinator.applyNetworkPolicy(context) + } HorizontalDivider() SettingToggle("Continue audio in the background", backgroundAudio) { backgroundAudio = it @@ -719,6 +1168,44 @@ private fun SettingsScreen( modifier = Modifier.padding(bottom = 14.dp), ) HorizontalDivider() + Text("Playback", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 20.dp)) + prefs?.let { current -> + SettingToggle("Play the next episode automatically", current.autoplayNext) { on -> + update { it.copy(autoplayNext = on) } + } + SettingToggle("Skip openings and endings automatically", current.autoSkip) { on -> + update { it.copy(autoSkip = on) } + } + Text( + "Openings and endings are read from the chapters a release was muxed with. " + + "With this off you get a Skip button instead, which is the safer default: " + + "a mis-named chapter then costs a tap rather than a scene.", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 14.dp), + ) + SettingToggle("Show subtitles", current.subtitles) { on -> + update { it.copy(subtitles = on) } + } + // Language tags, not a picker: which languages exist is a property + // of each file, and a list built from one episode is wrong for the + // next. A show that has been given its own choice keeps it. + LanguageField("Preferred audio language", current.audioLanguage) { tag -> + update { it.copy(audioLanguage = tag) } + } + LanguageField("Preferred subtitle language", current.subtitleLanguage) { tag -> + update { it.copy(subtitleLanguage = tag) } + } + Text( + "Three-letter tags as they appear in the file — \"jpn\", \"eng\". Used for every " + + "title that has not been given a track choice of its own.", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 14.dp), + ) + } + HorizontalDivider() + Text("Appearance", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 20.dp)) + AppearancePicker() + HorizontalDivider() Text("Metadata enrichment", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 20.dp)) Text( if (state.metadataSettings.enabled) "On · ${state.metadataSettings.provider.displayName()}" @@ -728,12 +1215,42 @@ private fun SettingsScreen( "Enabling this sends library title names to the selected third-party provider over HTTPS.", style = MaterialTheme.typography.bodySmall, ) - FilledTonalButton(onClick = { showMetadata = true }, modifier = Modifier.padding(vertical = 10.dp)) { - Text("Configure metadata") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 10.dp)) { + FilledTonalButton(onClick = { showMetadata = true }) { Text("Configure metadata") } + // Every title looked up again, matched ones included: the way out of + // a library the provider answered wrong, which otherwise stays wrong + // for as long as the match is remembered. + FilledTonalButton( + onClick = { onMatchAgain() }, + enabled = state.metadataSettings.enabled && !state.refreshing, + ) { Text("Match everything again") } } HorizontalDivider() Text("Storage", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 20.dp)) Text("Offline media is encrypted at rest by Android and kept in app-private storage.") + Text( + "${state.storage.offlineCount} episodes offline · ${formatBytes(state.storage.offlineBytes)}", + modifier = Modifier.padding(top = 8.dp), + ) + if (state.storage.partialBytes > 0uL) { + Text( + "Unfinished downloads · ${formatBytes(state.storage.partialBytes)}", + style = MaterialTheme.typography.bodySmall, + ) + } + Text( + "Streaming cache · ${formatBytes(state.storage.cacheBytes)}", + style = MaterialTheme.typography.bodySmall, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 10.dp)) { + // The cache is rebuildable, so it goes without asking. Offline + // episodes are a choice the viewer made, so that one asks. + FilledTonalButton(onClick = onClearCache) { Text("Clear cache") } + FilledTonalButton( + onClick = { confirmDelete = true }, + enabled = state.storage.offlineCount > 0uL, + ) { Text("Delete all offline") } + } Text("proton-stream Android · GPL-3.0-or-later", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 24.dp)) Text( "This program comes with absolutely no warranty. You may redistribute it under the GNU GPL.", @@ -749,6 +1266,23 @@ private fun SettingsScreen( }) { Text("View notices") } } } + if (confirmDelete) { + AlertDialog( + onDismissRequest = { confirmDelete = false }, + title = { Text("Delete all offline episodes?") }, + text = { + Text( + "${state.storage.offlineCount} episodes (${formatBytes(state.storage.offlineBytes)}) " + + "will be removed from this device. Watch history is kept, and anything " + + "deleted can be downloaded again.", + ) + }, + confirmButton = { + Button(onClick = { confirmDelete = false; onRemoveAllOffline() }) { Text("Delete") } + }, + dismissButton = { FilledTonalButton(onClick = { confirmDelete = false }) { Text("Cancel") } }, + ) + } legalDocument?.let { document -> LegalDocumentDialog(document, onDismiss = { legalDocument = null }) } @@ -854,6 +1388,120 @@ private fun SettingToggle(label: String, checked: Boolean, onChecked: (Boolean) } } +/** + * Flavour, accent and gradients — the same three the desktop client offers. + * + * Every colour is resolved by Rust, so a swatch here is the colour the app will + * actually paint rather than an approximation of it, and the choice is stored in + * the file both clients read. + */ +@Composable +private fun AppearancePicker() { + val scope = rememberCoroutineScope() + var choice by remember { mutableStateOf(null) } + var swatches by remember { mutableStateOf>(emptyMap()) } + + suspend fun repaint(next: AppearanceRecord, store: Boolean) { + runCatching { + withContext(Dispatchers.IO) { + val engine = NativeRuntime.engine() + if (store) engine.setAppearance(next) + val palette = engine.previewPalette(next) + // Every accent as it would look in *this* flavour: a swatch row + // that keeps Mocha's pastels while Latte is selected is a row + // that lies about what the next tap does. + val row = AccentChoice.entries.associateWith { accent -> + Color(engine.previewPalette(next.copy(accent = accent)).accent.toInt()) + } + palette to row + } + }.onSuccess { (palette, row) -> + choice = next + swatches = row + AppearanceState.apply(palette) + } + } + + LaunchedEffect(Unit) { + runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().appearance() } } + .onSuccess { repaint(it, store = false) } + } + + val current = choice ?: return + Text("Palette", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 8.dp)) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 8.dp)) { + items(FlavorChoice.entries.toList(), key = { it.name }) { flavor -> + val selected = flavor == current.flavor + if (selected) { + Button(onClick = {}) { Text(flavor.label()) } + } else { + OutlinedButton(onClick = { + scope.launch { repaint(current.copy(flavor = flavor), store = true) } + }) { Text(flavor.label()) } + } + } + } + Text("Accent", style = MaterialTheme.typography.bodyMedium) + LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(vertical = 8.dp)) { + items(AccentChoice.entries.toList(), key = { it.name }) { accent -> + val swatch = swatches[accent] ?: MaterialTheme.colorScheme.surfaceVariant + Box( + Modifier + .size(36.dp) + .clip(CircleShape) + .background(swatch) + .border( + width = if (accent == current.accent) 3.dp else 1.dp, + color = if (accent == current.accent) { + MaterialTheme.colorScheme.onBackground + } else { + MaterialTheme.colorScheme.outline + }, + shape = CircleShape, + ) + .clickable { + scope.launch { repaint(current.copy(accent = accent), store = true) } + }, + ) + } + } + SettingToggle("Paint the accent as a gradient", current.gradients) { on -> + scope.launch { repaint(current.copy(gradients = on), store = true) } + } + Text( + "Off is the safer setting on a panel that bands: a slow ramp across a wide bar " + + "shows every step it is drawn from, and flat is better than striped.", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 14.dp), + ) +} + +/** What each palette family is called. The desktop client says the same. */ +private fun FlavorChoice.label() = when (this) { + FlavorChoice.PROTON -> "Proton" + FlavorChoice.LATTE -> "Catppuccin Latte" + FlavorChoice.FRAPPE -> "Catppuccin Frappé" + FlavorChoice.MACCHIATO -> "Catppuccin Macchiato" + FlavorChoice.MOCHA -> "Catppuccin Mocha" +} + +/** + * One language tag, committed as it is typed. + * + * Blank is a real answer and means "no preference" — the bridge stores it as + * absent, which is what leaves the choice to the container's own default track. + */ +@Composable +private fun LanguageField(label: String, value: String?, onChange: (String?) -> Unit) { + OutlinedTextField( + value = value.orEmpty(), + onValueChange = { onChange(it.trim().takeIf(String::isNotEmpty)) }, + label = { Text(label) }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) +} + @Composable private fun EmptyState(title: String, body: String) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -864,11 +1512,12 @@ private fun EmptyState(title: String, body: String) { } } -private fun formatBytes(bytes: ULong): String { +internal fun formatBytes(bytes: ULong): String { val value = bytes.toDouble() return when { value >= 1024 * 1024 * 1024 -> "%.1f GiB".format(value / (1024 * 1024 * 1024)) value >= 1024 * 1024 -> "%.1f MiB".format(value / (1024 * 1024)) - else -> "${bytes} bytes" + value >= 1024 -> "%.1f KiB".format(value / 1024) + else -> "$bytes bytes" } } diff --git a/android/app/src/main/java/io/narl/protonstream/ui/RemoteArtwork.kt b/android/app/src/main/java/io/narl/protonstream/ui/RemoteArtwork.kt index 9d2d820..cec3a87 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/RemoteArtwork.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/RemoteArtwork.kt @@ -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(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(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 = 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( + (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 { diff --git a/android/app/src/main/java/io/narl/protonstream/ui/theme/Theme.kt b/android/app/src/main/java/io/narl/protonstream/ui/theme/Theme.kt index 26b88ae..b540b77 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/theme/Theme.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/theme/Theme.kt @@ -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(null) + val palette: StateFlow = 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), +) diff --git a/android/app/src/main/res/drawable/ic_notification_stream.xml b/android/app/src/main/res/drawable/ic_notification_stream.xml new file mode 100644 index 0000000..717443a --- /dev/null +++ b/android/app/src/main/res/drawable/ic_notification_stream.xml @@ -0,0 +1,13 @@ + + + + diff --git a/android/app/src/test/java/io/narl/protonstream/download/DownloadStateStoreTest.kt b/android/app/src/test/java/io/narl/protonstream/download/DownloadStateStoreTest.kt new file mode 100644 index 0000000..975ff1e --- /dev/null +++ b/android/app/src/test/java/io/narl/protonstream/download/DownloadStateStoreTest.kt @@ -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" + } +} diff --git a/android/app/src/test/resources/robolectric.properties b/android/app/src/test/resources/robolectric.properties new file mode 100644 index 0000000..a830672 --- /dev/null +++ b/android/app/src/test/resources/robolectric.properties @@ -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 diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index d7b8f36..a8e9d82 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -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" } diff --git a/crates/pstr-android/Cargo.toml b/crates/pstr-android/Cargo.toml index 9588b2c..c80b7e2 100644 --- a/crates/pstr-android/Cargo.toml +++ b/crates/pstr-android/Cargo.toml @@ -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" diff --git a/crates/pstr-android/src/lib.rs b/crates/pstr-android/src/lib.rs index b509f62..c7bc12b 100644 --- a/crates/pstr-android/src/lib.rs +++ b/crates/pstr-android/src/lib.rs @@ -4,16 +4,22 @@ //! than the catalog's internal Rust types. Kotlin owns lifecycle and drawing; //! Rust remains the sole owner of Proton sessions, SQLite and decrypted bytes. -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::ffi::c_void; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, OnceLock}; use parking_lot::Mutex; +use pstr_core::appearance::{Accent, Appearance, Flavor, Palette}; use pstr_core::catalog::{Catalog, OfflineFile, TitleTrackPrefs, WatchState, build_rows}; +use pstr_core::chapters::{Chapter, ChapterRole}; use pstr_core::config::AppDirs; use pstr_core::library::{Episode, Library, Title, TitleKind}; -use pstr_core::metadata::{MetadataConfig, MetadataRecord, ProviderId, TitleMetadata}; +use pstr_core::metadata::{ + EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata, +}; +use pstr_core::prefs::PlaybackPrefs; +use pstr_core::proton_drive_rs::{ProtonDrivePublicLinkClient, ThumbnailType}; use pstr_core::proton_sdk::ids::{LinkId, VolumeId}; use pstr_core::{SecretStore, ShareStore, SharedLibrary}; use pstr_stream::{ @@ -30,13 +36,29 @@ pub extern "system" fn Java_io_narl_protonstream_native_NativeRuntime_initTls( mut env: jni::JNIEnv<'_>, _class: jni::objects::JObject<'_>, context: jni::objects::JObject<'_>, -) { +) -> jni::sys::jboolean { android_logger::init_once( android_logger::Config::default() - .with_max_level(log::LevelFilter::Trace) + // A shipped APK writing rustls handshake internals to logcat is + // both noise and a disclosure; trace is for a build that asked for + // it. + .with_max_level(if cfg!(debug_assertions) { + log::LevelFilter::Debug + } else { + log::LevelFilter::Info + }) .with_tag("protonstream-rust"), ); - let _ = rustls_platform_verifier::android::init_with_env(&mut env, context); + match rustls_platform_verifier::android::init_with_env(&mut env, context) { + Ok(()) => jni::sys::JNI_TRUE, + Err(error) => { + // Every later HTTPS request will fail certificate validation, and + // without this the viewer only sees that much later and without a + // reason. + log::error!("certificate verifier init: {error}"); + jni::sys::JNI_FALSE + } + } } #[derive(Debug, thiserror::Error, uniffi::Error)] @@ -53,6 +75,19 @@ impl BridgeError { } } +/// Without this, uniffi routes an unmapped exception thrown by a Kotlin +/// callback into `handle_callback_unexpected_error`, which panics +/// unconditionally — on whichever thread happened to be calling, including a +/// tokio worker in the middle of a download. Every such exception is now an +/// ordinary `BridgeError` the caller can decide about. +impl From for BridgeError { + fn from(error: uniffi::UnexpectedUniFFICallbackError) -> Self { + Self::Failure { + reason: error.reason, + } + } +} + #[derive(Debug, Clone, uniffi::Record)] pub struct AndroidPaths { pub config: String, @@ -109,6 +144,61 @@ pub enum MetadataProvider { Tmdb, } +/// A palette family. The shared `pstr_core::appearance::Flavor`. +#[derive(Debug, Clone, Copy, uniffi::Enum)] +pub enum FlavorChoice { + Proton, + Latte, + Frappe, + Macchiato, + Mocha, +} + +/// The one strong colour. The shared `pstr_core::appearance::Accent`. +#[derive(Debug, Clone, Copy, uniffi::Enum)] +pub enum AccentChoice { + Mauve, + Pink, + Sky, + PinkSky, + Lavender, + Blue, + Teal, + Peach, +} + +/// What the viewer chose, stored where the desktop client reads it too. +#[derive(Debug, Clone, uniffi::Record)] +pub struct AppearanceRecord { + pub flavor: FlavorChoice, + pub accent: AccentChoice, + pub gradients: bool, +} + +/// One flavour and accent resolved into colours, packed as `0xAARRGGBB`. +/// +/// Resolved in Rust rather than in Kotlin on purpose: the ramps, the accent +/// pairings and the contrast rule that picks readable ink are one +/// implementation shared with the desktop client, so a flavour looks like +/// itself on both. +#[derive(Debug, Clone, uniffi::Record)] +pub struct PaletteRecord { + pub background: u32, + pub surface: u32, + pub sunken: u32, + pub card: u32, + pub card_hover: u32, + pub border: u32, + pub text: u32, + pub muted: u32, + pub accent: u32, + pub accent_alt: u32, + pub accent_dim: u32, + pub on_accent: u32, + pub danger: u32, + pub light: bool, +} + #[derive(Debug, Clone, uniffi::Record)] pub struct MetadataSettingsRecord { pub enabled: bool, @@ -117,6 +207,15 @@ pub struct MetadataSettingsRecord { pub ready: bool, } +/// What one enrichment pass did, for the viewer who pressed the button. +#[derive(Debug, Clone, Default, uniffi::Record)] +pub struct MatchSummary { + pub matched: u32, + pub unmatched: u32, + pub failed: u32, + pub episodes: u32, +} + #[derive(Debug, Clone, uniffi::Record)] pub struct MatchRecord { pub provider: MetadataProvider, @@ -149,6 +248,16 @@ pub struct EpisodeRecord { pub resume_at: Option, pub watched: bool, pub offline: bool, + /// What the metadata provider calls this episode, if it named it. + pub provider_name: Option, + pub provider_overview: Option, + pub still_url: Option, + pub air_date: Option, + /// When this episode was last played, as a Unix timestamp; 0 if never. + /// + /// What a continue-watching shelf orders by — "where I was last" is a + /// question about time, and progress alone cannot answer it. + pub last_played: i64, } #[derive(Debug, Clone, uniffi::Record)] @@ -183,6 +292,27 @@ pub struct TitleRecord { pub seasons: Vec, } +/// Playback preferences that outlive one file, one title and one launch. +/// +/// The bridge shape of [`pstr_core::prefs::PlaybackPrefs`] — deliberately the +/// same store the desktop client uses, so there is one definition of what a +/// viewer's language and volume choices are rather than one per front end. +#[derive(Debug, Clone, uniffi::Record)] +pub struct PlaybackPrefsRecord { + /// 0–100. + pub volume: f64, + pub muted: bool, + /// The language tag of the audio track to prefer — "jpn", "eng". `None` + /// leaves the choice to the container's default. + pub audio_language: Option, + pub subtitle_language: Option, + pub subtitles: bool, + pub autoplay_next: bool, + pub auto_skip: bool, + /// 1.0 is the file's own rate; the bridge clamps to what mpv can play. + pub speed: f64, +} + #[derive(Debug, Clone, uniffi::Record)] pub struct WatchStateRecord { pub position_secs: f64, @@ -209,12 +339,196 @@ pub struct OfflineRecord { pub episode: Option, } +/// One chapter as the JNI adapter read it out of mpv's `chapter-list`. +#[derive(Debug, Clone, uniffi::Record)] +pub struct ChapterRecord { + pub index: i64, + pub title: Option, + pub start: f64, +} + +/// What a chapter is, once the rest of the file has been taken into account. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum ChapterKind { + Opening, + Ending, + Preview, + Content, +} + +/// A chapter with everything the player needs to draw and jump to it. +#[derive(Debug, Clone, PartialEq, uniffi::Record)] +pub struct ChapterEntry { + pub index: i64, + /// Never empty: an unnamed chapter is still a place in the file. + pub label: String, + pub start: f64, + /// The next chapter's start, or the end of the file. `None` while the + /// duration is still unknown and this is the last chapter — there is + /// nowhere to skip to, and a button that seeks to zero is worse than none. + pub end: Option, + pub kind: ChapterKind, +} + +/// Every chapter of the open file, resolved. +#[derive(Debug, Clone, uniffi::Record)] +pub struct ChapterPlan { + pub entries: Vec, + /// Where the run of chapters that ends the episode begins — the point an + /// "up next" countdown starts from. `None` for a file that ends on content. + pub credits_start: Option, +} + +/// What the player may offer to skip right now. +#[derive(Debug, Clone, PartialEq, uniffi::Record)] +pub struct SkipOffer { + pub label: String, + /// Where skipping lands, which is the end of the chapter it skips. + pub target: f64, +} + +/// Resolve a file's chapters through the same rules the desktop player uses +/// (`pstr_core::chapters`), so an opening called `Intro` is read identically on +/// both. Kotlin reads the list out of mpv; the verdict is reached here. +#[uniffi::export] +pub fn chapter_plan(chapters: Vec, duration: Option) -> ChapterPlan { + let duration = duration.filter(|duration| *duration > 0.0); + let chapters: Vec = chapters + .into_iter() + .map(|chapter| Chapter { + index: chapter.index, + title: chapter + .title + .map(|title| title.trim().to_owned()) + .filter(|title| !title.is_empty()), + start: chapter.start, + }) + .collect(); + let roles = pstr_core::chapters::roles(&chapters, duration); + ChapterPlan { + credits_start: pstr_core::chapters::credits_start(&chapters, &roles), + entries: chapters + .iter() + .zip(&roles) + .enumerate() + .map(|(index, (chapter, role))| ChapterEntry { + index: chapter.index, + label: chapter.label(), + start: chapter.start, + end: pstr_core::chapters::chapter_end(&chapters, index, duration), + kind: chapter_kind(*role), + }) + .collect(), + } +} + +/// The one thing worth offering to skip at this position, if any. +/// +/// Nothing is offered once the end of the chapter is behind the playhead: the +/// last chapter of a file "ends" at the duration, and a skip button that sits +/// there through the credits is one that never goes away. +#[uniffi::export] +pub fn skip_offer(plan: &ChapterPlan, position: f64) -> Option { + let entry = plan + .entries + .iter() + .rposition(|entry| position + f64::EPSILON >= entry.start) + .and_then(|index| plan.entries.get(index))?; + let label = match entry.kind { + ChapterKind::Opening => "Skip opening", + ChapterKind::Ending => "Skip ending", + ChapterKind::Preview => "Skip preview", + ChapterKind::Content => return None, + }; + let target = entry.end.filter(|end| *end > position)?; + Some(SkipOffer { + label: label.to_owned(), + target, + }) +} + +fn chapter_kind(role: ChapterRole) -> ChapterKind { + match role { + ChapterRole::Opening => ChapterKind::Opening, + ChapterRole::Ending => ChapterKind::Ending, + ChapterRole::Preview => ChapterKind::Preview, + ChapterRole::Content => ChapterKind::Content, + } +} + +/// What the app is holding on disk. Offline episodes are the viewer's, the +/// block cache is not: only the latter may be reclaimed without asking. +#[derive(Debug, Clone, uniffi::Record)] +pub struct StorageUsageRecord { + pub offline_bytes: u64, + pub offline_count: u64, + /// Bytes in `.part` files of downloads that were paused or interrupted. + pub partial_bytes: u64, + pub cache_bytes: u64, +} + /// Implemented by a WorkManager worker. Cancellation is polled only between /// complete Proton blocks, so a retained `.part` file is always resumable. +/// +/// Both are fallible because the implementation is Android's: `setForegroundAsync` +/// throws routinely on API 31+ when the app is backgrounded, and a throw that +/// cannot be reported is a panic on the tokio worker carrying the download. #[uniffi::export(callback_interface)] pub trait DownloadObserver: Send + Sync { - fn on_progress(&self, downloaded: u64, total: u64); - fn is_cancelled(&self) -> bool; + fn on_progress(&self, downloaded: u64, total: u64) -> Result<(), BridgeError>; + fn is_cancelled(&self) -> Result; +} + +/// A spawned task that dies with the caller that is waiting on it. +/// +/// UniFFI cancellation drops the Rust future, and dropping a bare `JoinHandle` +/// *detaches* the task rather than aborting it — so backing out of a screen +/// leaves its provider requests and SQLite writes running, and a cancelled +/// download keeps calling back into a `DownloadObserver` WorkManager considers +/// dead. Abort points are await points, so a blocking SQLite statement always +/// completes; what is abandoned is the work after it. +struct AbortOnDrop(tokio::task::JoinHandle); + +impl Drop for AbortOnDrop { + fn drop(&mut self) { + self.0.abort(); + } +} + +/// `runtime.spawn(task)`, with the handle wrapped so the task is aborted if the +/// caller stops waiting. Awaits to exactly what `JoinHandle` does. +fn spawned( + runtime: &tokio::runtime::Runtime, + task: impl std::future::Future + Send + 'static, +) -> AbortOnDrop { + AbortOnDrop(runtime.spawn(task)) +} + +impl std::future::Future for AbortOnDrop { + type Output = std::result::Result; + + fn poll( + self: std::pin::Pin<&mut Self>, + context: &mut std::task::Context<'_>, + ) -> std::task::Poll { + std::pin::Pin::new(&mut self.get_mut().0).poll(context) + } +} + +/// A progress report that could not be delivered is not a reason to abandon a +/// transfer: the bytes are on disk either way, and the UI it feeds is +/// rebuildable from `DownloadStateStore`. +fn report(observer: &dyn DownloadObserver, downloaded: u64, total: u64) { + if let Err(error) = observer.on_progress(downloaded, total) { + log::warn!("download progress could not be reported: {error}"); + } +} + +/// An unanswerable cancellation question is answered "no": the download keeps +/// going, WorkManager stops the worker on its own schedule, and the retained +/// `.part` file makes the next attempt a resume rather than a restart. +fn cancelled(observer: &dyn DownloadObserver) -> bool { + observer.is_cancelled().unwrap_or(false) } /// A seekable revision for libmpv's Android stream callback. @@ -222,6 +536,18 @@ pub trait DownloadObserver: Send + Sync { pub struct AndroidStream { runtime: Arc, stream: VideoStream, + /// Tripped by libmpv's `cancel_fn`, through + /// [`pstr_android_stream_cancel`]. + /// + /// A block fetch is a network round trip on a 4 MiB body, and the demuxer + /// thread parked inside one cannot be joined until it returns. That thread + /// is what `mpv_terminate_destroy` waits on, so without a way to interrupt + /// the read, closing the player on a degraded connection blocks for a whole + /// block — and a seek issued during one does not take effect until it + /// finishes. + cancel: tokio::sync::Notify, + /// The registry id this stream was published under, once it has been. + native_id: Mutex>, } #[uniffi::export] @@ -237,8 +563,15 @@ impl AndroidStream { /// Publish this stream to the in-process libmpv adapter. The returned /// token contains no secret and is meaningful only in this process. pub fn native_handle(self: Arc) -> u64 { + // One id per stream: a second call must not mint a second registry + // entry, which nothing would ever release. + let mut published = self.native_id.lock(); + if let Some(id) = *published { + return id; + } let id = NEXT_NATIVE_STREAM.fetch_add(1, Ordering::Relaxed); - native_streams().lock().insert(id, self); + native_streams().lock().insert(id, Arc::clone(&self)); + *published = Some(id); id } } @@ -248,8 +581,14 @@ impl AndroidStream { /// UniFFI export: only `pstr_android_stream_read` may move these bytes, and /// it copies them directly into libmpv-owned memory. fn read_range_for_native(&self, offset: u64, length: u64) -> pstr_stream::Result> { - self.runtime - .block_on(self.stream.read_range(offset, length)) + self.runtime.block_on(async { + tokio::select! { + result = self.stream.read_range(offset, length) => result, + () = self.cancel.notified() => Err(pstr_stream::Error::NotFound( + "read cancelled".to_owned(), + )), + } + }) } } @@ -308,6 +647,23 @@ pub extern "C" fn pstr_android_stream_size(handle: u64) -> i64 { .unwrap_or(-1) } +/// Interrupt whatever read is in flight for this stream, from libmpv's +/// `cancel_fn`. +/// +/// Only an in-flight read is affected: a cancel that arrives with no reader +/// waiting is dropped, which is what makes this safe to call speculatively. The +/// read it aborts fails, and libmpv reissues it if it still wants those bytes — +/// a seek and a teardown both want the request slot back rather than the data. +#[unsafe(no_mangle)] +pub extern "C" fn pstr_android_stream_cancel(handle: u64) { + let _ = std::panic::catch_unwind(|| { + let stream = native_streams().lock().get(&handle).cloned(); + if let Some(stream) = stream { + stream.cancel.notify_waiters(); + } + }); +} + #[unsafe(no_mangle)] pub extern "C" fn pstr_android_stream_release(handle: u64) { let _ = std::panic::catch_unwind(|| { @@ -339,14 +695,41 @@ pub struct AndroidEngine { store: Arc, secrets: Arc, catalog: Mutex, - connection: tokio::sync::Mutex>, + /// The live connection, if one has been built for the current generation. + /// + /// Deliberately a sync mutex rather than the single-flight lock below: the + /// share mutations that invalidate it are sync `&self` methods called from + /// the JNI thread, and dropping a stale connection must happen *in* them + /// rather than being deferred to whoever next asks for a connection. + connection: Mutex>, + /// Held across the open handshakes so concurrent callers share one build. + connecting: tokio::sync::Mutex<()>, + /// Clients carried over from a retired connection, for the next build. + reusable_clients: Mutex>>, + /// The whole library as bridge records, behind the catalog's write counter. + library_cache: Mutex>, /// Serializes offline publication with share removal cleanup. share_publication: Mutex<()>, /// Advances after every successful share-store mutation. A connection is /// reusable only while it describes this exact generation of the store. share_generation: AtomicU64, + /// How many Proton thumbnails may be in flight at once. + thumbnails: tokio::sync::Semaphore, + /// When each share's visitor session was last refreshed. + /// + /// Sessions are short-lived and the app is routinely backgrounded for hours + /// between one episode and the next, so age — not just a share-store + /// mutation — is a reason to reauthenticate before opening anything. + session_refreshed: Mutex>, } +/// How long a visitor session is assumed good for without a refresh. +/// +/// A refresh costs one round trip. Not refreshing costs an authentication +/// failure that, before this, the viewer could only clear with a manual +/// pull-to-refresh. +const SESSION_FRESH_FOR: std::time::Duration = std::time::Duration::from_secs(5 * 60); + #[uniffi::export] impl AndroidEngine { #[uniffi::constructor] @@ -374,9 +757,14 @@ impl AndroidEngine { store, secrets: secret_store, catalog: Mutex::new(catalog), - connection: tokio::sync::Mutex::new(None), + connection: Mutex::new(None), + connecting: tokio::sync::Mutex::new(()), + reusable_clients: Mutex::new(None), + library_cache: Mutex::new(None), share_publication: Mutex::new(()), share_generation: AtomicU64::new(0), + thumbnails: tokio::sync::Semaphore::new(THUMBNAIL_CONCURRENCY), + session_refreshed: Mutex::new(HashMap::new()), })) } @@ -406,7 +794,30 @@ impl AndroidEngine { .store .add(&name, &url, custom_password.as_deref()) .map_err(BridgeError::from_display)?; - self.invalidate_connection(); + self.invalidate_connection(&share.id); + Ok(ShareRecord { + id: share.id, + name: share.name, + has_custom_password: share.has_custom_password, + }) + } + + /// Re-supply the link behind a share whose stored secret is unreadable. + /// + /// The way back from an invalidated Keystore key, which otherwise makes + /// every share permanently unopenable and takes the catalog and the offline + /// files with it if the answer is Remove-and-re-add. + pub fn repair_share( + &self, + share_id: String, + url: String, + custom_password: Option, + ) -> Result { + let share = self + .store + .replace_secrets(&share_id, &url, custom_password.as_deref()) + .map_err(BridgeError::from_display)?; + self.invalidate_connection(&share.id); Ok(ShareRecord { id: share.id, name: share.name, @@ -441,7 +852,7 @@ impl AndroidEngine { // ShareStore removes the config row before deleting its secret. Even // when secret cleanup fails, old authenticated clients and catalog // rows must not remain usable. - self.invalidate_connection(); + self.invalidate_connection(&share_id); for ((stored_share_id, link_id), file) in offline { if stored_share_id == share_id { @@ -467,39 +878,56 @@ impl AndroidEngine { store_result.map_err(BridgeError::from_display) } - pub fn library(&self, search: Option) -> Result, BridgeError> { + /// Drop catalog rows for offline files whose bytes are gone or truncated. + /// + /// Its own entry point rather than a side effect of reading the library: + /// four table scans and a `stat` per offline file is not what a keystroke + /// in the search box should cost, and a query that writes cannot be cached. + pub fn prune_offline_files(&self) -> Result { let catalog = self.catalog.lock(); - let files = catalog.all_files().map_err(BridgeError::from_display)?; - let watch = catalog - .all_watch_states() - .map_err(BridgeError::from_display)?; - let metadata = catalog.all_metadata().map_err(BridgeError::from_display)?; - let mut offline = catalog + let offline = catalog .all_offline_files() .map_err(BridgeError::from_display)?; - let mut invalid = Vec::new(); - offline.retain(|(share_id, link_id), file| { - let path = self.dirs.offline_file(share_id, link_id, &file.revision_id); + let mut pruned = 0; + for ((share_id, link_id), file) in offline { + let path = self + .dirs + .offline_file(&share_id, &link_id, &file.revision_id); let valid = std::fs::metadata(path) .is_ok_and(|metadata| metadata.len() == file.block_sizes.iter().sum::()); if !valid { - invalid.push((share_id.clone(), link_id.clone())); + catalog + .remove_offline_file(&share_id, &link_id) + .map_err(BridgeError::from_display)?; + pruned += 1; } - valid - }); - for (share_id, link_id) in invalid { - catalog - .remove_offline_file(&share_id, &link_id) - .map_err(BridgeError::from_display)?; } - let library = Library::build(files, &watch); - let titles: Vec<&Title> = match search { - Some(query) => library.search(&query), - None => library.titles.iter().collect(), - }; + Ok(pruned) + } + + /// The library, filtered by `search`. + /// + /// The whole conversion is cached behind the catalog's write counter, so a + /// search is a filter over records that already exist rather than four + /// table scans, a `Library::build` and a full record conversion per + /// keystroke. + pub fn library(&self, search: Option) -> Result, BridgeError> { + let titles = self.all_title_records()?; + let needle = search.unwrap_or_default().trim().to_lowercase(); + if needle.is_empty() { + return Ok(titles); + } Ok(titles .into_iter() - .map(|title| title_record(title, &offline, metadata.get(&title.key))) + .filter(|title| { + title.name.to_lowercase().contains(&needle) + || title.seasons.iter().any(|season| { + season + .episodes + .iter() + .any(|episode| episode.name.to_lowercase().contains(&needle)) + }) + }) .collect()) } @@ -549,33 +977,14 @@ impl AndroidEngine { .map_err(BridgeError::from_display) } - pub async fn match_titles(self: Arc, force: bool) -> Result<(), BridgeError> { + pub async fn match_titles(self: Arc, force: bool) -> Result { let runtime = Arc::clone(&self.runtime); - runtime - .spawn(async move { - let service = self.metadata_service()?; - let provider = service.provider(); - let (titles, stored) = self.titles_and_metadata()?; - for title in titles { - let existing = stored.get(&title.key); - let pinned = - existing.is_some_and(|record| record.manual && record.provider == provider); - if pinned || (!force && pstr_meta::service::is_usable(existing, provider)) { - continue; - } - let record = service - .record(&title) - .await - .map_err(BridgeError::from_display)?; - self.catalog - .lock() - .set_metadata(&record) - .map_err(BridgeError::from_display)?; - } - Ok(()) - }) - .await - .map_err(BridgeError::from_display)? + spawned( + &runtime, + async move { self.match_titles_inner(force).await }, + ) + .await + .map_err(BridgeError::from_display)? } pub async fn search_matches( @@ -584,18 +993,17 @@ impl AndroidEngine { term: String, ) -> Result, BridgeError> { let runtime = Arc::clone(&self.runtime); - runtime - .spawn(async move { - let service = self.metadata_service()?; - let title = self.title(&title_key)?; - service - .search(&term, title.kind) - .await - .map_err(BridgeError::from_display) - .map(|matches| matches.into_iter().map(match_record).collect()) - }) - .await - .map_err(BridgeError::from_display)? + spawned(&runtime, async move { + let service = self.metadata_service()?; + let title = self.title(&title_key)?; + service + .search(&term, title.kind) + .await + .map_err(BridgeError::from_display) + .map(|matches| matches.into_iter().map(match_record).collect()) + }) + .await + .map_err(BridgeError::from_display)? } pub fn choose_match(&self, title_key: String, found: MatchRecord) -> Result<(), BridgeError> { @@ -665,15 +1073,20 @@ impl AndroidEngine { .map_err(BridgeError::from_display) } + /// What this title was last watched as, if anything was ever chosen for it. + /// + /// `None` is not the same as the defaults: a title with no choice of its own + /// falls back to [`Self::playback_prefs`], which is how a language picked on + /// one show carries to the next. pub fn title_track_preferences( &self, title_key: String, - ) -> Result { + ) -> Result, BridgeError> { self.catalog .lock() .title_track_prefs(&title_key) .map_err(BridgeError::from_display) - .map(|prefs| track_preferences_record(prefs.unwrap_or_default())) + .map(|prefs| prefs.map(track_preferences_record)) } pub fn set_title_track_preferences( @@ -692,6 +1105,46 @@ impl AndroidEngine { .map_err(BridgeError::from_display) } + /// The preferences that hold across titles and launches. + /// + /// A per-title choice (`title_track_preferences`) overrides these for the + /// title it was made on; these are what a title with no choice of its own + /// starts from, and what the desktop client reads from the same file. + pub fn playback_prefs(&self) -> Result { + pstr_core::prefs::load(&self.dirs) + .map_err(BridgeError::from_display) + .map(playback_prefs_record) + } + + pub fn set_playback_prefs(&self, prefs: PlaybackPrefsRecord) -> Result<(), BridgeError> { + // Sanitized rather than trusted: the volume arrives from a slider, and + // a language from a text field that can hold spaces and nothing else. + let prefs = playback_prefs(prefs).sanitized(); + pstr_core::prefs::save(&self.dirs, &prefs).map_err(BridgeError::from_display) + } + + pub fn appearance(&self) -> Result { + pstr_core::appearance::load(&self.dirs) + .map_err(BridgeError::from_display) + .map(appearance_record) + } + + pub fn set_appearance(&self, appearance: AppearanceRecord) -> Result<(), BridgeError> { + pstr_core::appearance::save(&self.dirs, &appearance_choice(appearance)) + .map_err(BridgeError::from_display) + } + + /// The colours the stored choice resolves to. + pub fn palette(&self) -> Result { + self.appearance().map(|record| palette_record(&record)) + } + + /// The colours a choice *would* resolve to, without storing it — what a + /// picker previews as the viewer moves through the flavours. + pub fn preview_palette(&self, appearance: AppearanceRecord) -> PaletteRecord { + palette_record(&appearance) + } + pub fn offline_files(&self) -> Result, BridgeError> { let catalog = self.catalog.lock(); let files = catalog @@ -707,7 +1160,7 @@ impl AndroidEngine { for episode in title.episodes() { episodes.insert( (episode.node.share_id.clone(), episode.node.link_id.clone()), - episode_record(episode, &files), + episode_record(episode, &files, None), ); } } @@ -727,6 +1180,72 @@ impl AndroidEngine { Ok(records) } + /// What the app is holding on disk, counted from the files themselves + /// rather than from the catalog: a `.part` left by a paused download has no + /// catalog row, and it is exactly the space a viewer cannot account for. + pub fn storage_usage(&self) -> Result { + let offline = self + .catalog + .lock() + .all_offline_files() + .map_err(BridgeError::from_display)?; + let mut offline_bytes = 0; + let mut offline_count = 0; + for ((share_id, link_id), file) in &offline { + let path = self.dirs.offline_file(share_id, link_id, &file.revision_id); + if let Ok(metadata) = std::fs::metadata(path) { + offline_bytes += metadata.len(); + offline_count += 1; + } + } + Ok(StorageUsageRecord { + offline_bytes, + offline_count, + partial_bytes: directory_bytes(&self.dirs.offline_content(), Some("part")), + cache_bytes: directory_bytes(&self.dirs.block_cache(), None), + }) + } + + /// Forget every offline episode. The block cache is untouched — it is a + /// cache — and so is anything a running download owns, which the caller + /// cancels first. + pub async fn remove_all_offline(self: Arc) -> Result<(), BridgeError> { + let runtime = Arc::clone(&self.runtime); + spawned(&runtime, async move { + let offline = self + .catalog + .lock() + .all_offline_files() + .map_err(BridgeError::from_display)?; + for (share_id, link_id) in offline.keys() { + self.remove_offline_episode_inner(share_id, link_id).await?; + } + // Then sweep the directory, because the catalog is not a complete + // account of what is on disk: a `.part` from an interrupted download + // has no catalog row at all, and it is exactly the file this button + // exists to reclaim. Callers cancel outstanding work first, so + // nothing here is being written to. + sweep_directory(&self.dirs.offline_content()); + Ok(()) + }) + .await + .map_err(BridgeError::from_display)? + } + + /// Drop the cached blocks. Rebuildable by definition: this is the one thing + /// here that can be reclaimed without losing something the viewer chose. + pub fn clear_block_cache(&self) -> Result { + let cache = self.dirs.block_cache(); + let reclaimed = directory_bytes(&cache, None); + match std::fs::remove_dir_all(&cache) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(BridgeError::from_display(error)), + } + std::fs::create_dir_all(&cache).map_err(BridgeError::from_display)?; + Ok(reclaimed) + } + pub async fn open_stream( self: Arc, share_id: String, @@ -734,18 +1253,19 @@ impl AndroidEngine { link_id: String, ) -> Result, BridgeError> { let runtime = Arc::clone(&self.runtime); - runtime - .spawn(async move { - let stream = self - .open_stream_inner(&share_id, &volume_id, &link_id) - .await?; - Ok(Arc::new(AndroidStream { - runtime: Arc::clone(&self.runtime), - stream, - })) - }) - .await - .map_err(BridgeError::from_display)? + spawned(&runtime, async move { + let stream = self + .open_stream_inner(&share_id, &volume_id, &link_id) + .await?; + Ok(Arc::new(AndroidStream { + runtime: Arc::clone(&self.runtime), + stream, + cancel: tokio::sync::Notify::new(), + native_id: Mutex::new(None), + })) + }) + .await + .map_err(BridgeError::from_display)? } pub async fn download_episode( @@ -756,13 +1276,12 @@ impl AndroidEngine { observer: Box, ) -> Result { let runtime = Arc::clone(&self.runtime); - runtime - .spawn(async move { - self.download_episode_inner(&share_id, &volume_id, &link_id, observer) - .await - }) - .await - .map_err(BridgeError::from_display)? + spawned(&runtime, async move { + self.download_episode_inner(&share_id, &volume_id, &link_id, observer) + .await + }) + .await + .map_err(BridgeError::from_display)? } pub async fn remove_offline_episode( @@ -771,10 +1290,11 @@ impl AndroidEngine { link_id: String, ) -> Result<(), BridgeError> { let runtime = Arc::clone(&self.runtime); - runtime - .spawn(async move { self.remove_offline_episode_inner(&share_id, &link_id).await }) - .await - .map_err(BridgeError::from_display)? + spawned(&runtime, async move { + self.remove_offline_episode_inner(&share_id, &link_id).await + }) + .await + .map_err(BridgeError::from_display)? } pub async fn release_stream( @@ -782,36 +1302,165 @@ impl AndroidEngine { share_id: String, volume_id: String, link_id: String, - ) { + ) -> Result<(), BridgeError> { let runtime = Arc::clone(&self.runtime); - let _ = runtime - .spawn(async move { - let generation = self.share_generation.load(Ordering::Acquire); - if let Some(connection) = self - .connection - .lock() - .await - .as_ref() - .filter(|connection| connection.generation == generation) - { - connection - .source - .close(&share_id, &node_uid(&volume_id, &link_id)); - } - }) - .await; + spawned(&runtime, async move { + // Unconditional: a share mutated while the player was open does not + // make this stream any less open, and skipping the close leaks the + // reader and its read-ahead for the life of the source. + let source = self + .connection + .lock() + .as_ref() + .map(|connection| connection.source.clone()); + if let Some(source) = source { + source.close(&share_id, &node_uid(&volume_id, &link_id)); + } + }) + .await + .map_err(BridgeError::from_display) + } + + /// Proton's own thumbnail for one file, decrypted, as encoded image bytes. + /// + /// This is the poster of last resort, and with metadata lookups off — the + /// privacy default — it is the *only* artwork a freshly crawled library + /// has. `None` means the file has no thumbnail at all, which is common + /// rather than exceptional: Proton renders them at upload time, so a share + /// filled by a client that attaches none has none. The caller has to + /// remember that answer, or every recomposition pays a round trip for it. + pub async fn thumbnail( + self: Arc, + share_id: String, + volume_id: String, + link_id: String, + ) -> Result>, BridgeError> { + let runtime = Arc::clone(&self.runtime); + spawned(&runtime, async move { + self.thumbnail_inner(&share_id, &volume_id, &link_id).await + }) + .await + .map_err(BridgeError::from_display)? } pub async fn crawl(self: Arc, share_id: Option) -> Result<(), BridgeError> { let runtime = Arc::clone(&self.runtime); - runtime - .spawn(async move { self.crawl_inner(share_id).await }) + spawned(&runtime, async move { self.crawl_inner(share_id).await }) .await .map_err(BridgeError::from_display)? } } impl AndroidEngine { + /// One enrichment pass over the whole library. + /// + /// Two kinds of work, and the difference is a request saved: a title that + /// already has a good match but no episode list needs the episode request + /// only. Both run fanned out under a semaphore, and a provider error counts + /// against the run rather than ending it — one rate-limited title must not + /// leave the other thirty unenriched. This mirrors the desktop engine's + /// `run_match`, which is the reference for the behaviour. + async fn match_titles_inner(&self, force: bool) -> Result { + let service = Arc::new(self.metadata_service()?); + let provider = service.provider(); + let (titles, stored) = self.titles_and_metadata()?; + let listed = self + .catalog + .lock() + .episode_metadata_ages() + .map_err(BridgeError::from_display)?; + + let mut pending: Vec = Vec::new(); + for title in titles { + let record = stored.get(&title.key); + // A hand-picked entry is never searched for again, not even by + // "match again" — that button means "the automatic answers are + // wrong", and re-deciding the one title the viewer already fixed by + // hand is the opposite of what they asked for. Its episode list is + // still fetched below if it is missing. + let pinned = record.is_some_and(|record| record.manual && record.provider == provider); + if !pinned && (force || !pstr_meta::service::is_usable(record, provider)) { + pending.push(Work::Match(title)); + continue; + } + let has_episodes = listed + .get(&title.key) + .is_some_and(|(asked, _)| *asked == provider); + if let Some(found) = record.and_then(|record| record.metadata.clone()) + && !has_episodes + { + pending.push(Work::Episodes(title, Box::new(found))); + } + } + + let permits = Arc::new(tokio::sync::Semaphore::new(LOOKUP_CONCURRENCY)); + let mut lookups = tokio::task::JoinSet::new(); + for work in pending { + let service = Arc::clone(&service); + let permits = Arc::clone(&permits); + lookups.spawn(async move { + let _permit = permits.acquire().await.ok()?; + Some(match work { + Work::Match(title) => match service.record(&title).await { + Ok(record) => { + // Only for a title that matched: there is no id to + // ask about otherwise. + let episodes = match &record.metadata { + Some(found) => service.title_episodes(&title, found).await, + None => Vec::new(), + }; + Ok((record.title_key.clone(), Some(record), episodes)) + } + Err(error) => Err(error.to_string()), + }, + Work::Episodes(title, found) => { + let episodes = service.title_episodes(&title, &found).await; + Ok((title.key, None, episodes)) + } + }) + }); + } + + let mut summary = MatchSummary::default(); + while let Some(joined) = lookups.join_next().await { + match joined.map_err(BridgeError::from_display)? { + Some(Ok((title_key, record, episodes))) => { + if let Some(record) = &record { + if record.metadata.is_some() { + summary.matched += 1; + } else { + summary.unmatched += 1; + } + // Misses are stored on purpose; failures are not. + if let Err(error) = self.catalog.lock().set_metadata(record) { + log::warn!("store metadata for {}: {error}", record.title_key); + } + } + if !episodes.is_empty() { + summary.episodes += episodes.len() as u32; + let stored = self.catalog.lock().set_episode_metadata( + &title_key, + provider, + now(), + &episodes, + ); + if let Err(error) = stored { + log::warn!("store episodes for {title_key}: {error}"); + } + } + } + Some(Err(error)) => { + summary.failed += 1; + log::warn!("metadata lookup: {error}"); + } + // The semaphore is never closed; this is unreachable in + // practice and is not a failure of the title if it happens. + None => {} + } + } + Ok(summary) + } + fn metadata_service(&self) -> Result { let config = pstr_meta::settings::load(&self.dirs).map_err(BridgeError::from_display)?; if !config.enabled { @@ -823,6 +1472,49 @@ impl AndroidEngine { pstr_meta::MetadataService::new(&config, key).map_err(BridgeError::from_display) } + /// Every title as a bridge record, rebuilt only when the catalog moved. + fn all_title_records(&self) -> Result, BridgeError> { + let catalog = self.catalog.lock(); + let writes = catalog.writes(); + if let Some(cached) = self.library_cache.lock().as_ref() + && cached.writes == writes + { + return Ok(cached.titles.clone()); + } + + let files = catalog.all_files().map_err(BridgeError::from_display)?; + let watch = catalog + .all_watch_states() + .map_err(BridgeError::from_display)?; + let metadata = catalog.all_metadata().map_err(BridgeError::from_display)?; + let guides = catalog + .all_episode_metadata() + .map_err(BridgeError::from_display)?; + let offline = catalog + .all_offline_files() + .map_err(BridgeError::from_display)?; + drop(catalog); + + let library = Library::build(files, &watch); + let titles: Vec = library + .titles + .iter() + .map(|title| { + title_record( + title, + &offline, + metadata.get(&title.key), + guides.get(&title.key), + ) + }) + .collect(); + *self.library_cache.lock() = Some(CachedLibrary { + writes, + titles: titles.clone(), + }); + Ok(titles) + } + fn titles_and_metadata( &self, ) -> Result<(Vec, HashMap<String, MetadataRecord>), BridgeError> { @@ -845,10 +1537,108 @@ impl AndroidEngine { }) } - fn invalidate_connection(&self) { + /// The cached connection, if it still describes the current store. + fn cached_connection(&self) -> Option<Connection> { + let generation = self.share_generation.load(Ordering::Acquire); + self.connection + .lock() + .as_ref() + .filter(|opened| opened.generation == generation) + .map(|opened| Connection { + generation, + library: Arc::clone(&opened.library), + open_failures: opened.open_failures.clone(), + source: opened.source.clone(), + }) + } + + /// Retire the cached connection after a mutation of `share_id`. + /// + /// The cached `Connection` is dropped here rather than left to be noticed + /// later, so a removed share's authenticated client stops being reachable + /// at the moment the share stops existing. Every *other* share's client is + /// kept for the next build: adding one link is not a reason to re-handshake + /// the links that did not change. + fn invalidate_connection(&self, share_id: &str) { + let retired = self.connection.lock().take(); + let mut reusable = self.reusable_clients.lock(); + match retired { + Some(retired) => *reusable = Some(retired.library.reusable_clients(share_id)), + // Two mutations in a row: the first parked the set, and this one + // still has to evict its own share from it. + None => { + if let Some(parked) = reusable.as_mut() { + parked.remove(share_id); + } + } + } + drop(reusable); + self.session_refreshed.lock().remove(share_id); self.share_generation.fetch_add(1, Ordering::AcqRel); } + async fn thumbnail_inner( + &self, + share_id: &str, + volume_id: &str, + link_id: &str, + ) -> Result<Option<Vec<u8>>, BridgeError> { + // Keyed by share and link rather than revision: a re-encoded file keeps + // its link, and one stale frame of the right episode is a better answer + // than a round trip on every render. Clearing the cache directory is + // what invalidates it, which is also what a recrawl does not need. + let path = self + .dirs + .thumbnail_cache() + .join(format!("{share_id}-{link_id}.bin")); + match tokio::fs::read(&path).await { + Ok(bytes) if !bytes.is_empty() => return Ok(Some(bytes)), + // A truncated entry is not worth reporting — fetch it again. + Ok(_) | Err(_) => {} + } + + // Past the cache, so this one costs the network. Take a permit before + // any of the bandwidth playback might want. + let _permit = self + .thumbnails + .acquire() + .await + .map_err(BridgeError::from_display)?; + let connection = self.connection().await?; + let Some(client) = connection.library.client(share_id) else { + return Ok(None); + }; + let uid = node_uid(volume_id, link_id); + + // Preview first: it is the one sized for a card. Not every file has + // one, and the smaller thumbnail still beats a placeholder. + let mut found = None; + for kind in [ThumbnailType::Preview, ThumbnailType::Thumbnail] { + match client.download_thumbnail(&uid, kind).await { + Ok(Some(bytes)) => { + found = Some(bytes); + break; + } + Ok(None) => {} + Err(error) => log::debug!("thumbnail {share_id}/{link_id} ({kind:?}): {error}"), + } + } + let Some(bytes) = found else { + return Ok(None); + }; + + if let Some(parent) = path.parent() { + let _ = tokio::fs::create_dir_all(parent).await; + } + // Renamed into place, so a kill mid-write leaves no half-poster that + // every later launch reads and fails to decode. + let temporary = path.with_extension("part"); + if tokio::fs::write(&temporary, &bytes).await.is_ok() { + let _ = tokio::fs::rename(&temporary, &path).await; + } + Ok(Some(bytes)) + } + fn partial_paths( &self, share_id: &str, @@ -863,22 +1653,19 @@ impl AndroidEngine { } async fn connection(&self) -> Result<Connection, BridgeError> { - let mut connection = self.connection.lock().await; + if let Some(opened) = self.cached_connection() { + return Ok(opened); + } + let _building = self.connecting.lock().await; loop { - let generation = self.share_generation.load(Ordering::Acquire); - if let Some(opened) = connection - .as_ref() - .filter(|opened| opened.generation == generation) - { - return Ok(Connection { - generation, - library: Arc::clone(&opened.library), - open_failures: opened.open_failures.clone(), - source: opened.source.clone(), - }); + // A concurrent builder may have finished while this one waited. + if let Some(opened) = self.cached_connection() { + return Ok(opened); } + let generation = self.share_generation.load(Ordering::Acquire); + let reusable = self.reusable_clients.lock().take().unwrap_or_default(); - let (library, failures) = SharedLibrary::open_all(&self.store) + let (library, failures) = SharedLibrary::open_all_reusing(&self.store, reusable) .await .map_err(BridgeError::from_display)?; let open_failures: Vec<String> = failures @@ -901,7 +1688,7 @@ impl AndroidEngine { if self.share_generation.load(Ordering::Acquire) != generation { continue; } - *connection = Some(Connection { + *self.connection.lock() = Some(Connection { generation, library: Arc::clone(&library), open_failures: open_failures.clone(), @@ -916,6 +1703,54 @@ impl AndroidEngine { } } + /// Open a revision from the network, reauthenticating around it. + /// + /// Two guards, because there are two ways to lose a visitor session. Age is + /// the common one — the app sits in the background for hours and the next + /// episode opens against a session Proton has already retired — and a + /// timestamp catches it before the request. Everything else (a session + /// dropped early, a device that slept through a clock change) only shows up + /// as a failure, so one retry behind a refresh covers it. Before this, + /// either meant an authentication error the viewer could clear only by + /// pulling to refresh the library. + async fn open_from_source( + &self, + connection: &Connection, + share_id: &str, + uid: &NodeUid, + ) -> Result<VideoStream, BridgeError> { + let stale = self + .session_refreshed + .lock() + .get(share_id) + .is_none_or(|at| at.elapsed() >= SESSION_FRESH_FOR); + if stale { + self.refresh_session(connection, share_id).await; + } + match connection.source.open(share_id, uid).await { + Ok(stream) => Ok(stream), + Err(_) if !stale => { + self.refresh_session(connection, share_id).await; + connection + .source + .open(share_id, uid) + .await + .map_err(BridgeError::from_display) + } + Err(error) => Err(BridgeError::from_display(error)), + } + } + + /// Best-effort: a refresh that fails leaves the open to report the real + /// problem, which is more useful than a refresh error standing in for it. + async fn refresh_session(&self, connection: &Connection, share_id: &str) { + if connection.library.refresh_session(share_id).await.is_ok() { + self.session_refreshed + .lock() + .insert(share_id.to_owned(), std::time::Instant::now()); + } + } + async fn crawl_inner(&self, share_id: Option<String>) -> Result<(), BridgeError> { let connection = self.connection().await?; let targets: Vec<String> = match share_id { @@ -930,6 +1765,9 @@ impl AndroidEngine { .map_err(|error| BridgeError::Failure { reason: format!("refresh session for {id}: {error}"), })?; + self.session_refreshed + .lock() + .insert(id.clone(), std::time::Instant::now()); let before = self .catalog .lock() @@ -1007,11 +1845,8 @@ impl AndroidEngine { } let connection = self.connection().await?; - connection - .source - .open(share_id, &node_uid(volume_id, link_id)) + self.open_from_source(&connection, share_id, &node_uid(volume_id, link_id)) .await - .map_err(BridgeError::from_display) } async fn download_episode_inner( @@ -1021,12 +1856,21 @@ impl AndroidEngine { link_id: &str, observer: Box<dyn DownloadObserver>, ) -> Result<OfflineRecord, BridgeError> { + // A download that is already complete must not need the network to say + // so. Opening a stream first would make re-running a finished worker — + // WorkManager does, after a reboot or a constraint flap — fail on a + // plane with an episode sitting whole on disk. + if let Some(record) = self + .completed_offline_download(share_id, link_id, observer.as_ref()) + .await? + { + return Ok(record); + } + let connection = self.connection().await?; - let stream = connection - .source - .open(share_id, &node_uid(volume_id, link_id)) - .await - .map_err(BridgeError::from_display)?; + let stream = self + .open_from_source(&connection, share_id, &node_uid(volume_id, link_id)) + .await?; let revision_id = stream.revision_id().to_owned(); let block_sizes = stream.block_sizes().to_vec(); let total = stream.size(); @@ -1047,7 +1891,7 @@ impl AndroidEngine { remove_file_if_present(partial).await?; remove_file_if_present(marker).await?; sync_parent(&path)?; - observer.on_progress(total, total); + report(observer.as_ref(), total, total); return Ok(OfflineRecord { share_id: share_id.to_owned(), link_id: link_id.to_owned(), @@ -1075,11 +1919,11 @@ impl AndroidEngine { .unwrap_or(0); let (mut block_index, mut offset) = resume_position(existing, &block_sizes); let mut output = prepare_partial_file(&temporary, offset).await?; - observer.on_progress(offset, total); + report(observer.as_ref(), offset, total); use tokio::io::AsyncWriteExt; while block_index < block_sizes.len() { - if observer.is_cancelled() { + if cancelled(observer.as_ref()) { output.sync_all().await.map_err(BridgeError::from_display)?; return Err(BridgeError::Failure { reason: "offline download cancelled".to_owned(), @@ -1110,7 +1954,7 @@ impl AndroidEngine { .map_err(BridgeError::from_display)?; offset += size; block_index += 1; - observer.on_progress(offset, total); + report(observer.as_ref(), offset, total); } output.sync_all().await.map_err(BridgeError::from_display)?; drop(output); @@ -1136,6 +1980,44 @@ impl AndroidEngine { }) } + /// The record for a download whose bytes are already whole on disk, or + /// `None` when there is real work to do. Republishes the catalog row and + /// clears any stale partial, so a retried worker converges without a fetch. + async fn completed_offline_download( + &self, + share_id: &str, + link_id: &str, + observer: &dyn DownloadObserver, + ) -> Result<Option<OfflineRecord>, BridgeError> { + let Some(file) = self + .catalog + .lock() + .offline_file(share_id, link_id) + .map_err(BridgeError::from_display)? + else { + return Ok(None); + }; + let path = self.dirs.offline_file(share_id, link_id, &file.revision_id); + let total: u64 = file.block_sizes.iter().sum(); + if !tokio::fs::metadata(&path) + .await + .is_ok_and(|metadata| metadata.len() == total) + { + return Ok(None); + } + let (partial, marker) = self.partial_paths(share_id, link_id); + remove_file_if_present(partial).await?; + remove_file_if_present(marker).await?; + report(observer, total, total); + Ok(Some(OfflineRecord { + share_id: share_id.to_owned(), + link_id: link_id.to_owned(), + revision_id: file.revision_id, + size: total, + episode: None, + })) + } + fn record_offline( &self, share_id: &str, @@ -1199,6 +2081,66 @@ impl AndroidEngine { } } +/// Bytes held below a directory, optionally counting one extension only. +/// +/// Unreadable entries are skipped rather than failing the walk: this answers a +/// line in a settings screen, and a number that is short by one file is worth +/// more than an error where a number should be. +/// Delete every file under `root`, keeping the directory itself. +/// +/// Best-effort per entry: a file that cannot be removed is not a reason to leave +/// the rest of the gigabytes in place. +fn sweep_directory(root: &std::path::Path) { + let Ok(entries) = std::fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let _ = if path.is_dir() { + std::fs::remove_dir_all(&path) + } else { + std::fs::remove_file(&path) + }; + } +} + +/// How deep [`directory_bytes`] will walk. +/// +/// A bound rather than a policy: the cache is two levels deep, and a symlink +/// cycle under it would otherwise be an uncatchable stack overflow reached from +/// the settings screen. +const MAX_WALK_DEPTH: u32 = 16; + +fn directory_bytes(root: &std::path::Path, extension: Option<&str>) -> u64 { + directory_bytes_within(root, extension, MAX_WALK_DEPTH) +} + +fn directory_bytes_within(root: &std::path::Path, extension: Option<&str>, depth: u32) -> u64 { + let Ok(entries) = std::fs::read_dir(root) else { + return 0; + }; + entries + .flatten() + .map(|entry| { + let path = entry.path(); + if path.is_dir() { + return match depth.checked_sub(1) { + Some(remaining) => directory_bytes_within(&path, extension, remaining), + None => 0, + }; + } + let wanted = extension.is_none_or(|wanted| { + path.extension() + .is_some_and(|found| found.eq_ignore_ascii_case(wanted)) + }); + match wanted { + true => std::fs::metadata(&path).map(|file| file.len()).unwrap_or(0), + false => 0, + } + }) + .sum() +} + async fn remove_file_if_present(path: std::path::PathBuf) -> Result<(), BridgeError> { match tokio::fs::remove_file(path).await { Ok(()) => Ok(()), @@ -1324,6 +2266,33 @@ fn normalized_language(language: Option<String>) -> Option<String> { }) } +/// The library conversion, valid while the catalog has not been written to. +struct CachedLibrary { + writes: u64, + titles: Vec<TitleRecord>, +} + +/// One title's share of an enrichment pass. +enum Work { + /// Search for it, and take its episodes if it matches. + Match(Title), + /// It is already matched; only the episode list is missing. + Episodes(Title, Box<TitleMetadata>), +} + +/// How many provider lookups may be in flight at once. +/// +/// Deliberately small. Both providers rate-limit by client, and a phone +/// enriching a library it just crawled is competing with its own playback for +/// the same connection — the desktop engine uses the same number. +const LOOKUP_CONCURRENCY: usize = 2; + +/// How many Proton thumbnails may be fetched at once. +/// +/// Same number as the desktop engine. They are small and wanted on every +/// render, but they are still competing with playback for one connection. +const THUMBNAIL_CONCURRENCY: usize = 6; + fn now() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -1340,6 +2309,98 @@ fn watch_state_record(state: WatchState) -> WatchStateRecord { } } +fn appearance_record(appearance: Appearance) -> AppearanceRecord { + AppearanceRecord { + flavor: match appearance.flavor { + Flavor::Proton => FlavorChoice::Proton, + Flavor::Latte => FlavorChoice::Latte, + Flavor::Frappe => FlavorChoice::Frappe, + Flavor::Macchiato => FlavorChoice::Macchiato, + Flavor::Mocha => FlavorChoice::Mocha, + }, + accent: match appearance.accent { + Accent::Mauve => AccentChoice::Mauve, + Accent::Pink => AccentChoice::Pink, + Accent::Sky => AccentChoice::Sky, + Accent::PinkSky => AccentChoice::PinkSky, + Accent::Lavender => AccentChoice::Lavender, + Accent::Blue => AccentChoice::Blue, + Accent::Teal => AccentChoice::Teal, + Accent::Peach => AccentChoice::Peach, + }, + gradients: appearance.gradients, + } +} + +fn appearance_choice(record: AppearanceRecord) -> Appearance { + Appearance { + flavor: match record.flavor { + FlavorChoice::Proton => Flavor::Proton, + FlavorChoice::Latte => Flavor::Latte, + FlavorChoice::Frappe => Flavor::Frappe, + FlavorChoice::Macchiato => Flavor::Macchiato, + FlavorChoice::Mocha => Flavor::Mocha, + }, + accent: match record.accent { + AccentChoice::Mauve => Accent::Mauve, + AccentChoice::Pink => Accent::Pink, + AccentChoice::Sky => Accent::Sky, + AccentChoice::PinkSky => Accent::PinkSky, + AccentChoice::Lavender => Accent::Lavender, + AccentChoice::Blue => Accent::Blue, + AccentChoice::Teal => Accent::Teal, + AccentChoice::Peach => Accent::Peach, + }, + gradients: record.gradients, + } +} + +fn palette_record(record: &AppearanceRecord) -> PaletteRecord { + let palette = Palette::resolve(appearance_choice(record.clone())); + PaletteRecord { + background: palette.background.argb(), + surface: palette.surface.argb(), + sunken: palette.sunken.argb(), + card: palette.card.argb(), + card_hover: palette.card_hover.argb(), + border: palette.border.argb(), + text: palette.text.argb(), + muted: palette.muted.argb(), + accent: palette.accent.argb(), + accent_alt: palette.accent_alt.argb(), + accent_dim: palette.accent_dim.argb(), + on_accent: palette.on_accent.argb(), + danger: palette.danger.argb(), + light: palette.light, + } +} + +fn playback_prefs_record(prefs: PlaybackPrefs) -> PlaybackPrefsRecord { + PlaybackPrefsRecord { + volume: prefs.volume, + muted: prefs.muted, + audio_language: prefs.audio_language, + subtitle_language: prefs.subtitle_language, + subtitles: prefs.subtitles, + autoplay_next: prefs.autoplay_next, + auto_skip: prefs.auto_skip, + speed: prefs.speed, + } +} + +fn playback_prefs(record: PlaybackPrefsRecord) -> PlaybackPrefs { + PlaybackPrefs { + volume: record.volume, + muted: record.muted, + audio_language: normalized_language(record.audio_language), + subtitle_language: normalized_language(record.subtitle_language), + subtitles: record.subtitles, + autoplay_next: record.autoplay_next, + auto_skip: record.auto_skip, + speed: record.speed, + } +} + fn track_preferences_record(preferences: TitleTrackPrefs) -> TrackPreferencesRecord { TrackPreferencesRecord { audio_language: preferences.audio_language, @@ -1352,6 +2413,7 @@ fn title_record( title: &Title, offline: &std::collections::HashMap<(String, String), pstr_core::catalog::OfflineFile>, record: Option<&MetadataRecord>, + guide: Option<&EpisodeGuide>, ) -> TitleRecord { let metadata = record.and_then(|record| record.metadata.as_ref()); TitleRecord { @@ -1387,7 +2449,7 @@ fn title_record( episodes: season .episodes .iter() - .map(|episode| episode_record(episode, offline)) + .map(|episode| episode_record(episode, offline, guide)) .collect(), }) .collect(), @@ -1461,8 +2523,16 @@ fn title_metadata(record: MatchRecord) -> TitleMetadata { fn episode_record( episode: &Episode, offline: &std::collections::HashMap<(String, String), pstr_core::catalog::OfflineFile>, + guide: Option<&EpisodeGuide>, ) -> EpisodeRecord { let node = &episode.node; + // Matched on the numbering the *filename* states, which is what + // `EpisodeGuide::get` is careful about — see its documentation for why the + // absolute-numbering fallback stops at season one. + let named = node + .parsed + .episode + .and_then(|number| guide?.get(node.parsed.season, number)); EpisodeRecord { share_id: node.share_id.clone(), volume_id: node.volume_id.clone(), @@ -1477,6 +2547,11 @@ fn episode_record( resume_at: episode.resume_at(), watched: episode.is_watched(), offline: offline.contains_key(&(node.share_id.clone(), node.link_id.clone())), + provider_name: named.and_then(|found| found.name.clone()), + provider_overview: named.and_then(|found| found.overview.clone()), + still_url: named.and_then(|found| found.still_url.clone()), + air_date: named.and_then(|found| found.air_date.clone()), + last_played: episode.last_played(), } } @@ -1492,10 +2567,11 @@ mod tests { use pstr_stream::{MemoryBlocks, VideoStream}; use super::{ - AndroidEngine, AndroidPaths, AndroidSecretStore, AndroidStream, BridgeError, PartialMarker, - match_record, node_uid, normalized_language, prepare_partial_file, - pstr_android_stream_read, pstr_android_stream_release, pstr_android_stream_size, - read_partial_marker, resume_position, title_metadata, write_partial_marker, + AndroidEngine, AndroidPaths, AndroidSecretStore, AndroidStream, BridgeError, ChapterKind, + ChapterRecord, PartialMarker, chapter_plan, match_record, node_uid, normalized_language, + prepare_partial_file, pstr_android_stream_read, pstr_android_stream_release, + pstr_android_stream_size, read_partial_marker, resume_position, skip_offer, title_metadata, + write_partial_marker, }; #[derive(Default)] @@ -1599,6 +2675,103 @@ mod tests { std::fs::remove_dir_all(root).expect("remove marker directory"); } + fn chapter(index: i64, title: &str, start: f64) -> ChapterRecord { + ChapterRecord { + index, + title: Some(title.to_owned()), + start, + } + } + + /// The bridge must reach the desktop's verdict, not a looser one: the whole + /// point of routing this through `pstr_core::chapters` is that an opening + /// called `Intro` is an opening on both clients or on neither. + #[test] + fn a_chapter_plan_resolves_openings_the_way_the_desktop_player_does() { + let plan = chapter_plan( + vec![ + chapter(0, "Intro", 0.0), + chapter(1, "Part A", 700.0), + chapter(2, "Part B", 2400.0), + chapter(3, "Cast", 4680.0), + ], + Some(4800.0), + ); + + // Eleven minutes called `Intro` is the story, not a theme song. + assert_eq!(plan.entries[0].kind, ChapterKind::Content); + assert_eq!(plan.entries[3].kind, ChapterKind::Ending); + assert_eq!(plan.entries[0].end, Some(700.0)); + assert_eq!(plan.credits_start, Some(4680.0)); + } + + #[test] + fn an_unnamed_chapter_is_still_labelled_and_a_missing_duration_ends_nowhere() { + let plan = chapter_plan( + vec![ + ChapterRecord { + index: 0, + title: Some(" ".to_owned()), + start: 0.0, + }, + chapter(1, "OP", 24.0), + ], + None, + ); + + assert_eq!(plan.entries[0].label, "Chapter 1"); + assert_eq!(plan.entries[0].end, Some(24.0)); + // Nowhere to skip to: the file's length is not known yet. + assert_eq!(plan.entries[1].end, None); + assert_eq!(skip_offer(&plan, 30.0), None); + } + + /// A skip is offered inside the opening and nowhere else — including at its + /// very last second, where seeking would land the viewer where they already + /// are, and after it, where the button would never go away. + #[test] + fn a_skip_is_offered_only_while_the_thing_to_skip_is_still_ahead() { + let plan = chapter_plan( + vec![ + chapter(0, "Part A", 0.0), + chapter(1, "OP", 90.0), + chapter(2, "Part B", 180.0), + chapter(3, "ED", 1320.0), + ], + Some(1440.0), + ); + + assert_eq!(skip_offer(&plan, 10.0), None, "content is not skippable"); + let offer = skip_offer(&plan, 100.0).expect("inside the opening"); + assert_eq!(offer.label, "Skip opening"); + assert_eq!(offer.target, 180.0); + assert_eq!(skip_offer(&plan, 180.0).map(|offer| offer.label), None); + assert_eq!( + skip_offer(&plan, 1400.0).map(|offer| offer.label), + Some("Skip ending".to_owned()), + ); + // The last chapter ends at the duration, and nothing is offered there. + assert_eq!(skip_offer(&plan, 1440.0), None); + } + + #[test] + fn storage_counts_partial_downloads_separately_from_finished_ones() { + let root = std::env::temp_dir().join(format!( + "pstr-android-storage-{}-{}", + std::process::id(), + super::now() + )); + let nested = root.join("share"); + std::fs::create_dir_all(&nested).expect("storage directory"); + std::fs::write(nested.join("episode.part"), b"partial").expect("partial"); + std::fs::write(nested.join("episode"), b"whole file").expect("whole"); + + assert_eq!(super::directory_bytes(&root, Some("part")), 7); + assert_eq!(super::directory_bytes(&root, None), 17); + assert_eq!(super::directory_bytes(&root.join("missing"), None), 0); + std::fs::remove_dir_all(root).expect("remove storage directory"); + } + #[test] fn language_preferences_are_trimmed_and_normalized() { assert_eq!( @@ -1645,6 +2818,8 @@ mod tests { let stream = Arc::new(AndroidStream { runtime, stream: VideoStream::offline(node_uid("volume", "link"), blocks, 1024), + cancel: tokio::sync::Notify::new(), + native_id: Mutex::new(None), }); let handle = stream.native_handle(); diff --git a/crates/pstr-app/src/engine.rs b/crates/pstr-app/src/engine.rs index f7b15e9..f0301de 100644 --- a/crates/pstr-app/src/engine.rs +++ b/crates/pstr-app/src/engine.rs @@ -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() diff --git a/crates/pstr-app/src/theme.rs b/crates/pstr-app/src/theme.rs index 30efb32..b42c3d6 100644 --- a/crates/pstr-app/src/theme.rs +++ b/crates/pstr-app/src/theme.rs @@ -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); } } diff --git a/crates/pstr-core/src/appearance.rs b/crates/pstr-core/src/appearance.rs index b810c55..ee4ac42 100644 --- a/crates/pstr-core/src/appearance.rs +++ b/crates/pstr-core/src/appearance.rs @@ -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)); + } } diff --git a/crates/pstr-core/src/catalog.rs b/crates/pstr-core/src/catalog.rs index f93d0dd..892d40a 100644 --- a/crates/pstr-core/src/catalog.rs +++ b/crates/pstr-core/src/catalog.rs @@ -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 diff --git a/crates/pstr-core/src/chapters.rs b/crates/pstr-core/src/chapters.rs new file mode 100644 index 0000000..fe043dd --- /dev/null +++ b/crates/pstr-core/src/chapters.rs @@ -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); + } +} diff --git a/crates/pstr-core/src/lib.rs b/crates/pstr-core/src/lib.rs index 1d5bbf7..bd714b5 100644 --- a/crates/pstr-core/src/lib.rs +++ b/crates/pstr-core/src/lib.rs @@ -7,6 +7,7 @@ pub mod appearance; pub mod catalog; +pub mod chapters; pub mod config; pub mod error; pub mod library; diff --git a/crates/pstr-core/src/prefs.rs b/crates/pstr-core/src/prefs.rs index b94aa33..a208944 100644 --- a/crates/pstr-core/src/prefs.rs +++ b/crates/pstr-core/src/prefs.rs @@ -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); diff --git a/crates/pstr-core/src/shares.rs b/crates/pstr-core/src/shares.rs index 4f7a059..52b633c 100644 --- a/crates/pstr-core/src/shares.rs +++ b/crates/pstr-core/src/shares.rs @@ -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] diff --git a/crates/pstr-meta/src/service.rs b/crates/pstr-meta/src/service.rs index 18ffb0c..4f90fd9 100644 --- a/crates/pstr-meta/src/service.rs +++ b/crates/pstr-meta/src/service.rs @@ -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 { diff --git a/crates/pstr-player/Cargo.toml b/crates/pstr-player/Cargo.toml index cbbf077..f8de374 100644 --- a/crates/pstr-player/Cargo.toml +++ b/crates/pstr-player/Cargo.toml @@ -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 diff --git a/crates/pstr-player/src/chapters.rs b/crates/pstr-player/src/chapters.rs index 35335db..e25610e 100644 --- a/crates/pstr-player/src/chapters.rs +++ b/crates/pstr-player/src/chapters.rs @@ -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); - } -} diff --git a/crates/pstr-stream/src/source.rs b/crates/pstr-stream/src/source.rs index af5e782..534994c 100644 --- a/crates/pstr-stream/src/source.rs +++ b/crates/pstr-stream/src/source.rs @@ -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()); } } diff --git a/crates/pstr-stream/src/stream.rs b/crates/pstr-stream/src/stream.rs index cad44b4..56e6824 100644 --- a/crates/pstr-stream/src/stream.rs +++ b/crates/pstr-stream/src/stream.rs @@ -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] diff --git a/docs/ANDROID.md b/docs/ANDROID.md index 9399dfc..39b0680 100644 --- a/docs/ANDROID.md +++ b/docs/ANDROID.md @@ -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 diff --git a/docs/BUGS.md b/docs/BUGS.md index bcffc7a..cae589e 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -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, diff --git a/docs/TESTING.md b/docs/TESTING.md index 680d7ad..0b87e68 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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`: diff --git a/scripts/android-acceptance.py b/scripts/android-acceptance.py new file mode 100755 index 0000000..dbed21d --- /dev/null +++ b/scripts/android-acceptance.py @@ -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) diff --git a/scripts/android-acceptance.sh b/scripts/android-acceptance.sh new file mode 100755 index 0000000..4766f3e --- /dev/null +++ b/scripts/android-acceptance.sh @@ -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" "$@" diff --git a/scripts/build-android.sh b/scripts/build-android.sh index cac6765..bd2d921 100755 --- a/scripts/build-android.sh +++ b/scripts/build-android.sh @@ -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