B14-B42 record the Android review the theme and lifecycle work came out of. DESIGN-TOKENS.md states what both clients agree on and where each keeps its copy.
74 KiB
Bugs
The issue ledger. Open items first, then a log of what was fixed and where.
Format: Bn — one-line summary, then symptom, cause, fix, and how it was
verified. Reference entries from PRs.
Open
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-135reads the track list back immediately after an asynchronous property set, so the selection tick lags by up to a second;alang/slangare applied only at load (pstr_mpv.cpp:225-227), so a language change takes effect one episode late.- Landed.
NativeMpvHost.ktpolled onDispatchers.Main.immediate, andtracks_jsonissues fivempv_get_propertycalls 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 onDispatchers.Defaultand hops to the main thread only to invoke the state listener. - Landed.
PlaybackServiceconstructedNativeMpvHostinonCreatewith acheck(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 behindNativeMpvHost.createOrNull(), the service holds a nullable host, and the binder hands back a nullable one — which is whatPlayerScreenwas already written for. PlaybackService.kt:43constructsSettingsStoreon the main thread inside a hot callback; the first SharedPreferences access is disk I/O.- Landed.
MainActivitysetboundonly inonServiceConnected, so an activity destroyed with a bind still pending never unbound. It is now set from thebindServicereturn 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.
formatByteshad no KiB branch — a 512 KiB partial rendered as524288 bytes. - Landed.
ui/theme/Theme.ktoverrode ten roles ofdarkColorScheme()and leftsurfaceContainer*(what Card and NavigationSuite draw with) at the Material baseline, with no light scheme at all. The scheme is now built from the palettepstr_core::appearanceresolves — the same flavours, accent pairings and contrast rule the desktop client uses — so bothsurfaceContainer*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_bytesrecursed 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_surfaceleaked thejclassfromFindClass; it is released.nativeTracks/nativeChapterswere declared non-null on the Kotlin side, so a null fromNewStringUTFbecame an NPE swallowed byrunCatchinginto a silently empty track list; both are nullable now.escape()passed 4-byte UTF-8 toNewStringUTF, 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.ktduplicatedpstr_core::prefs::PlaybackPrefs(autoplaymost visibly), whichdocs/ANDROID.md:7says not to do. Playback preferences are now read and written over the bridge, andSettingsStoreholds only what is Android's alone — the Wi-Fi-only and background-audio settings.autoSkipwent the other way, intoPlaybackPrefs, where the desktop client can pick it up. - Landed.
ProtonStreamApp.ktcarried five unused imports, which was evidence that no lint gate ran. ktlint runs incheck.
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 as ApplySharedPref and
the PiP omission of B40
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 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
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
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 and B44.
Fixed
B52 — "paint the accent as a gradient" did nothing on Android
Symptom. The appearance page on Android offers a Paint the accent as a
gradient switch, stores it, and repaints the app when it is flipped — and
nothing on the screen changed either way. The same stored setting visibly
changed the desktop client, so one file meant two things depending on which
client read it. PinkSky, whose whole point is that it is a gradient before it
is a colour, arrived on Android as one flat pink.
Cause. No gradient was ever drawn. Nothing under android/app/src/main
referenced Brush, linearGradient or verticalGradient. accent_alt reached
Compose only as secondary, where it fills tonal containers — so the second hue
existed in the scheme but never sat next to the first one.
Fix. ui/theme/Accent.kt draws the ramp on the surfaces the desktop draws it
on: the primary button, the seek bar, the watch-progress bar under a tile and
under an episode row. Nothing there reads the toggle, because it does not need
to — Palette::resolve already sets accent_alt equal to accent when
gradients are off, so the ramp collapses to a flat fill by itself and the two
clients cannot disagree about what the setting means.
Two surfaces are deliberately not ramps. A download in flight keeps a solid
tertiary, because it is drawn directly under the watch-progress bar of the same
row and two ramps in the same colour two pixels apart say nothing. The navigation
pill is solid primary: NavigationSuiteScaffold takes its indicator as a
Color with no slot to draw into, so a gradient there would mean reimplementing
the bar — but the accent itself is still nearer the desktop's rule than
secondaryContainer, which is the accent taken most of the way back to the page.
Verified. ./gradlew :app:testDebugUnitTest :app:lintDebug. Still wants a
device: the ramp is the one thing here a screenshot on Proton and on Latte
settles, and the panel-banding case the toggle exists for cannot be judged from
a build at all.
B51 — the Android client wore only part of the palette it was given
Symptom. Every flavour looked half-applied on Android. The page was the flavour's colour and the tiles on it were not: the library grid, the continue-watching row, every episode row, every share, every download and the mini transport were all a flat neutral grey that belonged to no flavour and did not change when one was picked. Latte additionally drew white status-bar icons onto its near-white page, and every cold start flashed a dark blue-grey before the app appeared — including for viewers who had chosen Latte.
Cause. Four, all in how the resolved palette reached the platform.
schemeOf (ui/theme/Theme.kt) built a ColorScheme by copying
darkColorScheme()/lightColorScheme() and overriding 22 roles. Material names
roughly fifty. A role nobody overrides keeps Material's own baseline value, and
Card's container is surfaceContainerHighest
(FilledCardTokens.ContainerColor), which was one of the twenty-seven nobody
overrode. Card is what draws every repeated surface in the app, so the app's
most common surface was the one surface that ignored the theme. inverseSurface
and inverseOnSurface — the snackbar, which is the client's only error channel
— and scrim, the dim behind every dialog, were in the same set. surfaceTint
was too, so raised surfaces were tinted with Material's baseline purple.
themes.xml hardcoded windowBackground to #1E1E2E, which is Catppuccin
Mocha's base: not the shipped default, and not the viewer's choice either. It
also pinned windowLightStatusBar and windowLightNavigationBar to false,
and nothing anywhere synced either to palette.light.
ProtonStreamTheme read the palette in a LaunchedEffect that hopped to
Dispatchers.IO, so the first composition was always painted in a hardcoded
dark default — the same flash-of-the-wrong-theme the desktop client had on
Windows, for the same reason.
Fix. schemeOf maps every role Material names. Two colours the palette does
not name are derived in Rust so the blend behind them is the shared, tested one:
elevated, which continues the flavour's own card → card_hover step by
extrapolating past it — so a light flavour's ladder descends and a dark one's
climbs — and danger_dim, an error container rather than error ink. Both are
new fields on PaletteRecord. outline and outlineVariant were also swapped
to match what Material draws with each: the visible border takes muted, the
divider takes the fainter border. surfaceTint is transparent, because the
desktop draws no elevation tint and its rule is that the accent is the only
strong colour in the window.
A new stored_palette bridge function resolves the stored choice without
building an engine — no SQLite, no Tokio runtime, no Keystore unlock — so
MainActivity.onCreate can seed the palette before setContent rather than one
frame after it. It also sets the window background and the system bars' icon
polarity from that palette, and re-applies both whenever the flavour changes, so
picking Latte turns the status-bar icons dark in the same frame the page turns
light. themes.xml keeps only the shipped default's background, as a first-ever
launch has no theme file to read.
Verified. cargo test --workspace --locked, cargo clippy --workspace --all-targets -- -D warnings, ./gradlew :app:testDebugUnitTest :app:lintDebug
(lint clean; the two unmatched baseline entries it reports pre-date this change
— the same run on the unmodified tree reports them). The Kotlin side is pinned
by SchemeRolesTest, which sweeps ColorScheme reflectively rather than from a
list and fails if any role carries a value the palette did not supply, so a role
added by a future Material version fails the day the dependency moves. The Rust
side asserts the derived rung continues the ladder in all five flavours, both
polarities. The appearance itself still wants a device: screenshots on Proton
and on Latte.
B50 — every episode of a show was named after the show
Symptom. All fifty-nine Attack on Titan rows read Attack On Titan on both
lines — no numbering, nothing to tell one from the next. Oshi no Ko, in the same
library, was fine.
Cause. build_rows treats a filename title that differs from the folder's
as the episode's own name (catalog.rs), which is what turns
Neon Genesis Evangelion - Death & Rebirth.mkv under an NGE folder into an
episode called Death & Rebirth. Both the comparison and the prefix strip were
case-sensitive, and the two names come from different hands: the folder was
Attack on Titan, the release group wrote Attack On Titan. One letter made
them "different", the strip then found no prefix to remove, and the whole series
name was stored as episode_title — which Episode::label and
Episode::detail both prefer over the numbering.
Fix. Both are case-insensitive, and a strip that removes nothing no longer yields an episode name.
Verified. Two table tests in catalog.rs; cargo test --workspace. Note
that the wrong episode_title is stored, so an existing catalog needs a
re-crawl (the library's refresh) before rows correct themselves.
B49 — a recoverable decoder fallback put a dialog over a playing episode
Symptom. Every HEVC episode opened with
hevc_mediacodec: Both surface and native_window are NULL over the picture, and
then played normally.
Cause. Two things, both exposed by B46 finally letting the MediaCodec
decoders initialise. FFmpeg's direct MediaCodec decoders render into an
ANativeWindow handed to them at init; this player has none to give — the
picture goes through vo_libmpv on an EGL pbuffer, and the SurfaceView attaches
later than the load, if at all (background audio). hwdec=auto-safe picked them
regardless. Separately, record_log promoted any error-level mpv log line to
the UI, and libavcodec logs a decoder that refuses to initialise as an error
even where mpv treats it as a fallback.
Fix. hwdec=auto-copy-safe — the copy variants decode in hardware and hand
back ordinary frames, which is what this render path can use. And record_log
ignores the ffmpeg/… prefix: mpv reports the failures that are actually fatal
under a prefix of its own.
Verified. gradlew externalNativeBuildDebug.
B48 — tonal buttons were grey shapes on a grey page
Symptom. Download, Change match, Pause, the navigation bar's selected pill — every tonal control was a dark grey rounded rectangle a shade off the card under it. Readable as text, invisible as a control. Episode rows compounded it: forty rows each arranged themselves around whatever their own download state needed, so no two lined up.
Cause. Theme.kt mapped Material's secondaryContainer — which fills every
FilledTonalButton and the navigation indicator — to the palette's cardHover,
one step off card. And EpisodeRow gave each row a second line of buttons
whose widths depended on the row's own state.
Fix. secondaryContainer is accentDim, the accent taken back towards the
page, which is what the role is for. The episode row is one row: the whole card
plays, the numbering leads the text column, and the two trailing controls sit in
fixed-width slots — including an empty one — so every row aligns. Numbering is
always printed, falling back to the season folder's number and then to the row's
position, so rows the parser could not number still differ. Season headers carry
a count and an icon-only download. The page also ends above the floating mini
transport instead of behind it.
Verified. gradlew compileDebugKotlin.
B46 — every hardware decoder failed, and playback ran in software
Symptom. Starting an HEVC episode on Android raised
hevc_mediacodec: No Java virtual machine has been registered and played
anyway — decoded on the CPU, with the heat and dropped frames that implies on
anything above 1080p.
Cause. FFmpeg's MediaCodec decoders reach Android's Java API through a
JavaVM handed to them by av_jni_set_java_vm; there is no way for them to
find one themselves. mpv-android registers it in its own JNI layer
(app/src/main/jni/main.cpp), and pstr_mpv.cpp replaces that layer, so
nothing ever called it. hwdec=auto-safe then failed to open every MediaCodec
decoder and fell back to software.
Fix. JNI_OnLoad in pstr_mpv.cpp registers the VM — the loader calls it
once per process, before any native method and long before a decoder runs.
libmpv links FFmpeg dynamically without re-exporting it, so CMakeLists.txt
links libavcodec.so (already packaged as part of libmpv's staged dependency
closure) and scripts/build-libmpv-android.sh stages libavcodec/jni.h
alongside the mpv headers.
Verified. gradlew externalNativeBuildDebug; libpstr_mpv.so exports
JNI_OnLoad, imports av_jni_set_java_vm@LIBAVCODEC_63 and gains a
libavcodec.so DT_NEEDED.
B47 — episode labels were set one character per line
Symptom. On a phone, an episode row rendered "S03E01" down the screen a letter at a time, and the show screen did the same to "More on AniList".
Cause. Both were fixed Rows in which the flexible child came last: the
episode row gave a 96 dp still, a Play button, a watched toggle and a download
button their intrinsic widths and left Modifier.weight(1f) whatever remained,
which on a narrow screen is a few pixels. Compose does not drop a child to make
room; it wraps the text into the column it was given.
Fix. The episode row is two rows — still plus label above, controls below —
with the label ellipsized rather than wrapped without bound. The show screen's
two button rows are FlowRows, so a button that does not fit moves to the next
line instead of squeezing its neighbour. The season header gives its label the
weight so a long season name cannot squeeze "Download season".
Verified. gradlew compileDebugKotlin and ktlintMainSourceSetCheck.
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; 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 BridgeExceptions, 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
RemoteActions 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.
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:
- 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;. androidx.tracing.Trace, whichAndroidJUnitRunner.onCreateuses and nothing in the app reaches, was dropped.androidx.work.WorkManageris absent frommapping.txtentirely rather than renamed: the app only touches the implementation, so R8 vertically merged the abstract class intoWorkManagerImpland JUnit's field scan could not construct the test class.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.- The same for
SettingsStore.setWifiOnly, and then for the synthesisedDownloadCoordinatorKtfacade 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, volume, the pickers — sat flush against the bottom edge of the window and was clipped by it. The floating Skip opening button sat at the same height as the seek bar rather than above the controls, four pixels out of line with them.
Cause. CHROME_HEIGHT was a constant 132 px. The controls are two rows of
themed buttons whose height follows the theme and the platform's text scaling,
so the constant was a guess — and a guess that is low does not clip in egui, it
draws past the rectangle and off the window. The skip button then used its own
22 px inset where the controls used 26.
Fix. ui::player::chrome returns the height it actually measured
(ui.min_rect().height() plus padding), the app carries it in Overlay, and the
next frame uses it for the scrim and for floating anything above the controls.
The content is anchored to the bottom of the window rather than laid out from
the top of the strip, so the last row ends a fixed distance above the edge
whatever it turns out to contain. One frame of lag, and only while resizing.
floating puts both the skip button and the up-next card at the controls' own
CHROME_PAD_X.
B12 — the skip button believed a chapter's name
Symptom. Episode one of Oshi no Ko opens with a chapter called Intro that
is eleven minutes of the story. The player offered Skip opening over it.
Cause. ChapterRole::of read the first word of the name and nothing else,
mapping intro to Opening unconditionally. Nothing looked at how long the
chapter was or at what else the file contained.
Fix. Two passes — Claim from the name, roles from the name and the
file. A name that could go either way (Intro, Credits, Cast) is resolved
by length and position: an opening has to be theme-length, near the front, and
the only candidate in the file. Both ambiguities fail towards content. The same
pass is what credits_start reads, so the end-of-episode countdown does not
assume the last chapter is the ending either — see the Part C case in
docs/DEVELOPMENT.md.
Verified. a_long_intro_is_the_episode_and_not_an_opening,
a_theme_length_intro_near_the_front_is_an_opening,
content_after_the_ending_keeps_the_ending_out_of_the_tail.
B11 — seasons past the first had no episode names, and were one query from having the wrong ones
Symptom. Oshi no Ko seasons two and three showed filenames where season one
showed episode names. Season one only "worked" because its filenames carry
them (S01E01-Mother and Children.mkv); episode_metadata was empty for the
whole library.
Cause. Two, stacked. A title is looked up once, and on AniList that match is
the first season's entry — a sequel is a separate id numbering from one — so
nothing was ever asked about seasons two and three. And EpisodeGuide::get fell
back from (Some(2), 1) to the provider's absolute (None, 1), so the moment
season one's episodes were fetched, every later season would have been captioned
with season one's names. A wrong caption does not read as a bug; it reads as the
library being wrong.
Fix. Provider::seasons_are_separate_entries distinguishes the two
providers. For AniList, MetadataService::season_episodes searches each season
past the first by the name the provider uses (<title> 2nd Season, then
<title> Season 2) and tags the result with that season; TMDB already returns
seasons numbered and is left alone. The absolute fallback now stops at season
one.
Verified. an_absolutely_numbered_answer_never_reaches_a_later_season,
a_season_is_searched_for_the_way_the_provider_names_it. Against the live API,
[Oshi no Ko] 2nd Season is id 166531 with 13 episodes and 3rd Season is
182587 with 11 — which is exactly what the share holds.
Still missing for that show, and not a bug here. AniList's episode titles
come from streamingEpisodes, and all three Oshi no Ko entries have none at
all. Those rows keep their filenames until the viewer switches to TMDB.
B10 — every episode of season three was called "3rd Season"
Symptom. Under Oshi no Ko, all eleven rows of season three read
3rd Season instead of an episode name.
Cause. naming::parse knew S03 and Season 03 but not 3rd Season, so
[DB]Oshi no Ko 3rd Season_-_07_….mkv parsed as a title of Oshi no Ko 3rd Season. catalog::build_rows then took the difference between that and the
folder's Oshi no Ko to be the episode's own name — which is what it is for,
and here it was a season marker.
Fix. find_worded_season reads both orders (3rd Season, Season 3) and
ends the title there, so the season comes out of the filename as a season.
build_rows additionally asks naming::is_season_marker before keeping any
leftover as an episode name, and folds it into the season instead. The dash is
deliberately not a separator between Season and its number: Show 2nd Season - 04 puts the episode there, and reading across it made episode four season four.
Verified. underscore_separated_numbering_is_read,
a_worded_season_is_read_as_a_season,
an_ordinal_that_is_part_of_the_title_is_not_a_season,
a_season_stated_in_the_filename_does_not_become_the_episodes_name.
B9 — a series split into one poster per season on the shelf
Symptom. The library wall showed Game of Thrones - Season 1 … Season 8
as eight separate titles, each with its own poster and its own resume point.
Cause. naming::classify_folder recognised a season folder only when the
whole name was one (Season 01, S03). A folder named <Series> - Season 1
therefore classified as a title, and the season number stayed in the title
string, so the grouping key differed per season.
Fix. naming::trailing_season splits a … Season N / … SNN tail off a
folder name, and catalog::ancestry takes the season from a folder that names
both. The keyword is required — Evangelion 3 and Alien 3 keep their number,
and Blade Runner 2049 still reads as a year.
Verified. a_folder_naming_series_and_season_yields_both,
a_title_ending_in_a_number_keeps_it; on the real share, 30 titles became 23,
with Game of Thrones one poster of 73 episodes.
B5 — a multi-byte character in a filename panicked the parser
Symptom. The crawl aborted on
[Anime Time] Neon Genesis Evangelion - Death (True)²-007.mkv with
start byte index 34 is not a char boundary; it is inside '²'.
Cause. Year detection scanned bytes and sliced on byte offsets, so a token boundary could land inside a multi-byte character.
Fix. bare_year walks char_indices; bracketed_year verifies the four
bytes are ASCII digits before slicing.
Verified. a_multi_byte_character_does_not_break_parsing.
B4 — files were catalogued with no title
Symptom. Every episode in the catalog had an empty title: a real library is
laid out Sousou no Frieren/Season 01/S01E01.mkv, and S01E01.mkv states no
series at all.
Cause. Rows were built from the filename alone.
Fix. catalog::build_rows walks the folder path: the folder names the
series, the filename names the numbering, grouping folders (Movies, OVA,
Specials) are stepped over, and a Season NN folder supplies a season the
filename omitted. Non-video files (.nfo, .jpg) are dropped.
Verified. Seven build_rows tests, and a real 354-node share that went from
0 usable titles to 22 correct ones.
B3 — several naming conventions lost their episode numbers
Symptom. 35 of 310 files in a real share had no episode number.
Cause. Four gaps: a bare E11.mkv; a bare 11.mkv; _-_01_ underscore
separators; and — the subtle one — Oshi no Ko S2 - 04, where matching the
season returned, so the differently-stated episode was never looked for.
Fix. find_episode_marker, find_bare_number, underscore separators in
find_dash_numbering, and find_numbering continuing past a season-only match.
Verified. Down to 11 unnumbered files, all of which are films or OP/ED extras that genuinely have no episode number.
B1 — a public-link download silently truncated past 200 MiB
Symptom. download_file over a public link returned a file cut off at
exactly 50 blocks, with no error.
Cause. The visitor path issued a single un-paged
GET …/revisions/{rid} and used whatever Blocks came back. The endpoint pages
at 50.
Fix. proton-sdk-rs 0.3.3: the visitor path now uses the same paged
list_blocks as the authenticated one, and fetches blocks concurrently.
Verified. a_visitor_can_seek_within_a_shared_multi_block_file reads back a
9 MiB file whole and in ranges.
B2 — a catalog crawl re-derived the same keys per child
Symptom. Listing a folder of several hundred episodes over a public link took minutes.
Cause. resolve_parent_key walked and re-unlocked the whole ancestor chain
for every child. Each unlock is an S2K derivation — tens of milliseconds — so 500
siblings cost 500 redundant unlocks of the same parent key. enumerate_nodes
also never chunked its link ids, exceeding the server's 150-id batch limit.
Fix. proton-sdk-rs 0.3.3: an LRU node-key cache with an iterative
ancestor walk (up to the nearest cached key, then unlock back down caching each)
behind SingleFlight; enumerate_nodes chunked at 150 and fanned out.
B6 — read-ahead starved the seeks it was supposed to smooth
Symptom. Dragging to a new position took ~2.9 s with the block layer's read-ahead enabled, versus ~0.57 s with it turned off. Sustained throughput was also lower with read-ahead than without: 5.0 MiB/s against 7.2.
Cause. Prefetches queued from the position the viewer had just left kept running. The block they were fetching was exactly the bandwidth the seek needed, and a 4 MiB block over this link takes most of a second, so the seek waited behind up to six of them.
Fix. pstr-stream::stream tracks where the previous read ended and treats a
read landing more than one block away as a seek. A seek cancels every outstanding
prefetch before it fetches anything. Aborting mid-fetch throws those bytes
away, which is the intended trade — they were speculative, and the request slot
they held is what the seek is waiting for. Read-ahead resumes wherever the seek
settles.
Verified. Worst-case cold seek back to 715 ms with read-ahead on.
a_seek_cancels_the_read_ahead_it_invalidates and
sequential_playback_never_cancels_its_own_read_ahead pin both directions, and
a_block_whose_prefetch_was_cancelled_can_still_be_read pins that an aborted
prefetch releases its single-flight entry rather than leaving the block
permanently unfetchable.
B7 — a read-ahead window deeper than the ring evicted its own blocks
Symptom. Raising read-ahead from 12 to 32 blocks reduced sustained throughput, 8.7 MiB/s to 7.8, and the ring started reporting evictions.
Cause. The default 128 MiB ring holds 32 blocks of 4 MiB. A 32-block read-ahead window is the entire ring, so blocks at the front of the window were evicted before the player reached them and had to be fetched a second time.
Fix. clamp_readahead caps the window at half the ring's capacity in blocks
— half rather than all, because the ring also has to hold what the player has
just passed, so a scrub a few seconds backwards does not refetch. The default
depth is 12, chosen because it never evicted and wasted a third less bandwidth on
cancelled prefetches than 16 did, for indistinguishable throughput.
Verified. read_ahead_is_capped_at_half_the_rings_capacity and
a_tiny_ring_still_reads_one_block_ahead.
B8 — the block layer's read-ahead made playback worse once mpv was in front of it
Symptom. With pstr-stream's tuned default of 12 blocks, mpv took 5.5 s to
show a first frame and 3.6 s to resume after a mid-file seek. Turning the block
layer's read-ahead off entirely cut both to 2.5 s and 1.5 s, and fetched a
third as much data for the same playback.
Cause. B6 and
B7 were
measured against pstr bench, a reader with no read-ahead of its own — for
that reader, 12 blocks is right. mpv is not that reader. Its demuxer cache
already runs demuxer-readahead-secs ahead of the picture, sequentially and
eagerly, so a second speculative layer underneath adds no buffer at all. It only
competes for bandwidth with the reads mpv is blocked on right now, and it does so
worst exactly when the viewer is waiting: at startup and just after a seek. The
degradation is monotonic in depth.
| blocks ahead | first frame | seek resumed | blocks fetched |
|---|---|---|---|
| 0 | 2.5 s | 1479 ms | 12 |
| 6 | 3.7 s | 2225 ms | 21 |
| 12 | 5.5 s | 3581 ms | 31 |
| 24 | 7.7 s | 6122 ms | 42 |
Fix. pstr_player::READAHEAD_BLOCKS is 0, and it is what pstr play and the
app pass to StreamConfig. pstr_stream::DEFAULT_READAHEAD_BLOCKS stays 12 for
consumers that genuinely have no read-ahead — the benchmark, and anything that
reads a revision without a demuxer. mpv's buffer is also expressed in seconds
rather than bytes (PlayerConfig::readahead_seconds, 30 s), because a byte
budget buys two minutes of buffer on a 4 Mbit/s episode and eight seconds of it
on a 4K remux, which is backwards from what either wants; the byte figure is kept
only as a ceiling, sized under the ring so B7 cannot recur one layer up.
Verified. Sustained playback holds realtime at depth 0 (178 s of media in 180 s of wall clock, the deficit being the 2.4 s to first frame), with no ring evictions, against the same 761 MiB episode over a real public link.