vc=90: cold-start store hydration off-Main + video-page status-bar fix + RYD FFI slim
All checks were successful
build-apk / build-and-publish (push) Successful in 9m28s
gitleaks / scan (push) Successful in 1m3s

Resume + Enrichment stores no longer JSON-decode on the main thread in Application.onCreate (Resume is the heaviest cold-start cost: ~50-100ms decode at its 100k-entry cap). Both seed an empty StateFlow and hydrate off-thread on their PrefsWriter single-thread dispatcher. Mutations that arrive before the hydrate finishes defer onto that FIFO dispatcher (a hydrated gate) so they re-run on fully-loaded state -- correct for adds AND clears; a naive loaded+live merge would resurrect a clear that landed on the empty seed. Subscriptions/Playlists/History/Settings stay eager (tiny + rendered directly -> would flash empty). (audit 2.1)

Video page: details/related rows no longer leak into the status-bar strip above the player when scrolled. topPadding is now a layout inset on the detail LazyColumn, so the scroll viewport begins+clips at the player's bottom edge instead of letting rows scroll up over the clock/signal.

RYD: dropped the dead rating + view_count fields from the strawcore RydVotes FFI Record + Kotlin shim -- only likes/dislikes are rendered; RYD's rating restates the like/dislike ratio and its viewCount is a stale aggregate conflicting with the real YouTube count already shown. (audit #2 L-9)

Adversarially reviewed (Opus): clear-resurrection closed, no defer-loop, FFI slim has no readers. Headless compileDebugKotlin green.
This commit is contained in:
Cobb 2026-06-22 05:10:48 -07:00
parent 93bf86f534
commit 1e89c0739a
7 changed files with 150 additions and 25 deletions

View file

@ -9,6 +9,33 @@ const val STRAW_SDK_TARGET = 35
// Sulkta fork — Straw
//
// vc=90 / 0.1.0-CX — cold-start hydration + status-bar bleed fix + RYD FFI slim:
// * ResumePositionsStore + EnrichmentStore no longer JSON-decode on the main
// thread in Application.onCreate. Resume was the heaviest cold-start cost in
// the app — at its 100k-entry cap the blob is multi-MB and the decode runs
// ~50-100 ms on a low-end device, all on Main before the first frame. Both
// now seed an empty StateFlow and hydrate from disk on the store's own
// PrefsWriter single-thread dispatcher. Mutations that arrive before the
// hydrate finishes DEFER themselves onto that same FIFO dispatcher (a
// `hydrated` flag gate) so they re-run after the load on fully-loaded state
// — correct for adds AND clears (a naive `loaded + live` merge would
// resurrect a clear that landed on the empty seed). Subscriptions/Playlists/
// History/Settings stay eager: tiny (sub-ms) and rendered directly, so
// seeding them empty would flash an empty list every cold start. (audit 2.1)
// * Fixed the video page leaking details/related rows into the status-bar
// strip above the player when scrolled. The detail body fills the screen
// from y=0 (under the status bar) and the player only starts at the
// status-bar inset, so a leading Spacer-as-item let rows scroll up into the
// uncovered strip over the clock/signal. topPadding is now a layout inset on
// the LazyColumn itself, so the scroll viewport begins + clips at the
// player's bottom edge — rows can't enter the strip. (Cobb, on-device.)
// * RYD: dropped the dead `rating` + `viewCount` fields from the strawcore
// `RydVotes` FFI Record + the Kotlin shim. Only likes/dislikes were ever
// rendered; RYD's rating just restates the like/dislike ratio and its
// viewCount is a stale aggregate that conflicts with the real YouTube count
// the detail screen already shows — surfacing either would be redundant or
// misleading, so they no longer cross the FFI. (audit #2 L-9)
//
// vc=89 / 0.1.0-CW — pagination-burst fix + finish the SP-write serialization:
// * Hide-shorts no longer drains a whole channel/search in one burst. The
// infinite-scroll trigger is computed from the FILTERED list while loadMore()
@ -285,6 +312,6 @@ const val STRAW_SDK_TARGET = 35
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
// NewPipeExtractor in the runtime path.
const val STRAW_VERSION_CODE = 89
const val STRAW_VERSION_NAME = "0.1.0-CW"
const val STRAW_VERSION_CODE = 90
const val STRAW_VERSION_NAME = "0.1.0-CX"
const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -91,31 +91,32 @@ pub(crate) async fn read_capped_body(resp: reqwest::Response, cap: usize) -> Opt
/// Vote counts from the Return-YouTube-Dislike API. Kotlin maps this onto
/// its own `net.RydVotes` data class (the detail-screen overlay model).
///
/// We carry only the fields the UI actually renders (likes/dislikes). RYD's
/// own `rating` (a 0-5 restatement of the like/dislike ratio) and `viewCount`
/// (RYD's stale aggregate — the real YouTube count from `videoDetails` is what
/// the detail screen already shows) are intentionally NOT surfaced: rendering
/// either would be redundant or actively misleading, so they don't cross the
/// FFI (audit #2 L-9 — slimmed the dead carry-through).
#[derive(Debug, Clone, uniffi::Record)]
pub struct RydVotes {
pub id: String,
pub likes: i64,
pub dislikes: i64,
pub rating: f64,
pub view_count: i64,
}
#[derive(Deserialize)]
struct RydVotesWire {
// `id` is required (no default) to match the Kotlin original, whose
// non-nullable `id: String` made a response missing `id` fail to parse
// and return null. likes/dislikes/rating/viewCount keep defaults — the
// Kotlin data class defaulted those.
// and return null. likes/dislikes keep defaults — the Kotlin data class
// defaulted those. The JSON's `rating`/`viewCount` are simply not read
// (serde ignores unknown fields here) — see RydVotes above.
id: String,
#[serde(default)]
likes: i64,
#[serde(default)]
dislikes: i64,
#[serde(default)]
rating: f64,
#[serde(default)]
#[serde(rename = "viewCount")]
view_count: i64,
}
/// GET https://returnyoutubedislikeapi.com/votes?videoId=<id>
@ -143,8 +144,6 @@ pub async fn fetch_ryd_votes(video_id: String) -> Option<RydVotes> {
id: wire.id,
likes: wire.likes,
dislikes: wire.dislikes,
rating: wire.rating,
view_count: wire.view_count,
})
}

View file

@ -23,6 +23,7 @@ import android.content.SharedPreferences
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.updateAndGet
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@ -52,9 +53,33 @@ class EnrichmentStore(context: Context) {
// could land out of order. The write reads the live map so disk converges.
private val writer = PrefsWriter(sp)
private val _entries = MutableStateFlow(load())
// Seed empty + hydrate off the main thread (cold-start: this store can hold
// up to MAX_ENRICHMENTS=5000 entries, ~250 KB to JSON-decode in onCreate).
// Same clobber-safe pattern as ResumePositionsStore — merge loaded + live on
// the shared PrefsWriter FIFO dispatcher so a put() during the hydrate window
// survives and disk converges. No flash risk: this is overlay data the feed
// merges in on IO after the rows already paint, not a primary list.
private val _entries = MutableStateFlow<Map<String, Enrichment>>(emptyMap())
val entries: StateFlow<Map<String, Enrichment>> = _entries.asStateFlow()
// See ResumePositionsStore: put()/clear() that arrive before the hydrate
// finishes defer themselves onto the write dispatcher so they re-run on
// fully-loaded state (a clear on the empty seed would otherwise be undone).
@Volatile private var hydrated = false
init {
writer.serial {
try {
val loaded = load()
if (loaded.isNotEmpty()) _entries.update { live -> loaded + live }
} finally {
// See ResumePositionsStore: flip the gate even on a throw so a
// failed hydrate can't strand every mutation in a self-redispatch loop.
hydrated = true
}
}
}
/**
* Return a fresh enrichment for this videoId, or null when missing
* or aged out per Settings.cacheTtl. Forever-TTL never expires.
@ -69,6 +94,7 @@ class EnrichmentStore(context: Context) {
}
fun put(videoId: String, viewCount: Long, durationSeconds: Long) {
if (!hydrated) { writer.serial { put(videoId, viewCount, durationSeconds) }; return }
if (videoId.isBlank()) return
// Don't write all-zero entries — that's failure not data, and
// would waste a slot the cap could spend on a real hit.
@ -107,8 +133,11 @@ class EnrichmentStore(context: Context) {
}
fun clear() {
if (!hydrated) { writer.serial { clear() }; return }
val before = _entries.value
_entries.updateAndGet { emptyMap() }
writer.write { putString(KEY, json.encodeToString(_entries.value)) }
// Skip the SP write when already empty (matches ResumePositionsStore.clearAll).
if (before.isNotEmpty()) writer.write { putString(KEY, json.encodeToString(_entries.value)) }
}
private fun load(): Map<String, Enrichment> = runCatching {

View file

@ -44,4 +44,28 @@ class PrefsWriter(private val sp: SharedPreferences) {
editor.apply()
}
}
/**
* Run [block] on the SAME single-thread dispatcher as write(), FIFO with
* it. Two uses, both relying on that FIFO ordering:
*
* 1. HYDRATE a store's StateFlow from disk OFF the main thread at
* construction the JSON decode otherwise blocks Application.onCreate
* (ResumePositionsStore can hold up to 100k entries; its own comment
* pegs encode/decode at ~50-100 ms on a low-end device). Enqueue it
* from the store constructor, before the singleton is published, so it
* is FIFO-first.
* 2. DEFER a mutation that arrived before hydration finished. A mutation
* run on the empty seed would diverge from disk: an add is recoverable
* by merging disk under it, but a CLEAR is NOT `loaded + emptyLive`
* resurrects the just-cleared data (the empty live map carries no trace
* of the removal). So instead of merging-and-hoping, every mutation
* checks a `hydrated` flag and, if false, re-enqueues ITSELF here. The
* deferred copy runs after the FIFO-earlier hydrate, on fully-loaded
* state, so adds and clears are both correct. (The dispatcher is one
* worker; the deferred mutation provably observes `hydrated == true`.)
*/
fun serial(block: () -> Unit) {
StrawApp.globalScope.launch(dispatcher) { block() }
}
}

View file

@ -20,6 +20,7 @@ import android.content.SharedPreferences
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.updateAndGet
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@ -62,9 +63,39 @@ class ResumePositionsStore(context: Context) {
private val json = Json { ignoreUnknownKeys = true }
private val writer = PrefsWriter(sp)
private val _positions = MutableStateFlow(load())
// Seed empty + hydrate off the main thread. The synchronous decode this
// store used to do in its constructor (MutableStateFlow(load())) is the
// heaviest cold-start cost in the app: at MAX_RESUMES_HARD the blob is
// up to ~5 MB and the encode/decode runs ~50-100 ms (see record()'s note).
// We MERGE loaded + live so a record() that lands during the hydrate
// window survives, and because the hydrate shares the PrefsWriter FIFO
// dispatcher (enqueued before this store is published) it always runs
// before any write's persist — disk converges to the full set, no clobber.
private val _positions = MutableStateFlow<Map<String, ResumePosition>>(emptyMap())
val positions: StateFlow<Map<String, ResumePosition>> = _positions.asStateFlow()
// Flipped true at the end of the hydrate. Mutations that arrive before then
// defer themselves onto the write dispatcher (see record/clearOne/clearAll)
// so they re-run AFTER the hydrate on fully-loaded state — a clear on the
// empty seed would otherwise be silently undone when the merge restored
// the on-disk data the user just wiped.
@Volatile private var hydrated = false
init {
writer.serial {
try {
val loaded = load()
if (loaded.isNotEmpty()) _positions.update { live -> loaded + live }
} finally {
// Flip the gate even if load/merge throws. load() is runCatching-
// guarded today so it can't, but a stuck-false `hydrated` would turn
// every later mutation into an unbounded self-redispatch storm —
// keep that impossible locally, not by action-at-a-distance.
hydrated = true
}
}
}
private fun maxResumes(): Int {
val cap = Settings.get().resumePositionsCap.value.value
return cap.coerceAtMost(MAX_RESUMES_HARD)
@ -81,6 +112,7 @@ class ResumePositionsStore(context: Context) {
* REMOVED so a finished video doesn't auto-resume to its credits.
*/
fun record(videoId: String, positionMs: Long, durationMs: Long) {
if (!hydrated) { writer.serial { record(videoId, positionMs, durationMs) }; return }
if (videoId.isBlank()) return
if (durationMs <= 0L) return
if (positionMs < MIN_POSITION_MS) return
@ -138,13 +170,22 @@ class ResumePositionsStore(context: Context) {
}
}
/** Returns null when the video has no recorded position. */
/**
* Returns null when the video has no recorded position. Reads are NOT
* gated on hydration (unlike writes): in the brief cold-start window before
* the off-thread hydrate lands, this returns null even for a video that has
* a saved position on disk auto-resume then starts at 0 for that one open.
* Benign (the stored position is intact and resumes on the next open), and
* near-impossible in practice: playback only starts after stream extraction
* (a network round-trip), by which point the local-disk hydrate has run.
*/
fun get(videoId: String): ResumePosition? {
if (videoId.isBlank()) return null
return _positions.value[videoId]
}
fun clearOne(videoId: String) {
if (!hydrated) { writer.serial { clearOne(videoId) }; return }
if (videoId.isBlank()) return
val before = _positions.value
val next = _positions.updateAndGet { current ->
@ -156,6 +197,7 @@ class ResumePositionsStore(context: Context) {
}
fun clearAll() {
if (!hydrated) { writer.serial { clearAll() }; return }
val before = _positions.value
_positions.updateAndGet { emptyMap() }
if (before.isNotEmpty()) {

View file

@ -143,9 +143,16 @@ fun VideoDetailBody(
// now recycle and defer their image decode + 2 progress-overlay flow
// collectors until scrolled into view, instead of mounting ~40 rows
// (~40 decodes + ~80 collectors) eagerly on every video open.
LazyColumn(modifier = Modifier.fillMaxWidth()) {
item { Spacer(modifier = Modifier.height(topPadding)) }
//
// topPadding is applied as a layout inset on the LazyColumn itself, NOT a
// leading Spacer item. A Spacer-as-item scrolls away, letting rows slide up
// through the full-height body into the status-bar strip ABOVE the player —
// visible over the clock/signal, since the player (opaque) only starts at
// the status-bar inset and nothing occludes that strip. As padding, the
// scroll viewport begins at the player's bottom edge and clips there, so
// rows can never enter the status strip. (The player is opaque black, so
// the old "scroll behind the player" was invisible anyway — no feel change.)
LazyColumn(modifier = Modifier.fillMaxWidth().padding(top = topPadding)) {
when {
state.loading -> item {
Box(

View file

@ -17,22 +17,19 @@ data class RydVotes(
val id: String,
val likes: Long = 0,
val dislikes: Long = 0,
val rating: Double = 0.0,
val viewCount: Long = 0,
)
object RydClient {
/** Suspends on the Rust async runtime; call from a coroutine. Returns
* null on any failure (transport / non-2xx / bad JSON), same contract
* the old blocking client had. */
* the old blocking client had. Only likes/dislikes are carried RYD's
* rating/viewCount were dead, redundant overlay data (audit #2 L-9). */
suspend fun fetch(videoId: String): RydVotes? =
uniffi.strawcore.fetchRydVotes(videoId)?.let { v ->
RydVotes(
id = v.id,
likes = v.likes,
dislikes = v.dislikes,
rating = v.rating,
viewCount = v.viewCount,
)
}
}