vc=86: audit-fix sprint (HIGH H2 + 5 MED/LOW from the 2026-06-21 audit)
- Audio-only toggle no longer drops the max-resolution cap: both the fullscreen button (PlayerScreen) and the detail Audio pill (VideoDetailBody) rebuilt TrackSelectionParameters from a fresh Builder, wiping the data-saver ceiling. Now buildUpon() the existing params so the cap survives. (H2) - Subscriptions Refresh button no longer sticks at "..." forever on a warm restart within the cache TTL: refreshIfStale clears the initial loading seed when it decides nothing needs refreshing. (M2) - Search + Channel result lists get a stable item key (video url) so paging / shorts-filtering stops re-binding rows to new data (re-triggered thumbnail loads, scroll shift). (M3, M4) - IosSafeHttpDataSource: the unknown-length (LENGTH_UNSET) chunk path rolls forward to the next Range chunk at inner-EOF instead of re-reading the exhausted source forever (was truncating playback to the first chunk). (M5) - strawcore channel_feed_rss propagates the real failure (network/HTTP/parse) instead of collapsing every error to an empty list, so a broken fetch is distinguishable from "no new videos" (subscription_feed keeps its per-channel tolerance for fan-out). (M6) - Feed recency: a clock-skewed future upload emits "0 seconds ago" (parses to top) instead of "just now" (which Kotlin's recency parser couldn't read, so the item sank to the bottom). (L4) Deferred to a follow-up: M1 (bg-refresh cache-key mismatch — needs a worker redesign) + M7 (build config-cache wiring). Verified: cargo check/clippy + full Android compileDebugKotlin green.
This commit is contained in:
parent
a36e673627
commit
d6399b367d
8 changed files with 89 additions and 17 deletions
|
|
@ -9,6 +9,29 @@ const val STRAW_SDK_TARGET = 35
|
||||||
|
|
||||||
// Sulkta fork — Straw
|
// Sulkta fork — Straw
|
||||||
//
|
//
|
||||||
|
// vc=86 / 0.1.0-CT — audit-fix sprint (code-audit HIGH H2 + 5 MED/LOW):
|
||||||
|
// * Audio-only toggle no longer drops your max-resolution cap. Both the
|
||||||
|
// fullscreen button and the detail "Audio" pill rebuilt the track-
|
||||||
|
// selection params from a fresh Builder, wiping the data-saver ceiling;
|
||||||
|
// they now buildUpon() the existing params so the cap survives.
|
||||||
|
// * Subscriptions "Refresh" button no longer sticks at "..." forever on a
|
||||||
|
// warm restart within the cache TTL — refreshIfStale now clears the
|
||||||
|
// initial loading seed when it decides nothing needs refreshing.
|
||||||
|
// * Search + Channel result lists carry a stable item key (the video url)
|
||||||
|
// so appending a page / filtering shorts stops re-binding rows to new
|
||||||
|
// data (which re-triggered thumbnail loads + shifted scroll).
|
||||||
|
// * iOS-safe data source: the unknown-length (LENGTH_UNSET) chunk path now
|
||||||
|
// rolls forward to the next Range chunk at inner-EOF instead of
|
||||||
|
// re-reading the exhausted source forever (was truncating playback to
|
||||||
|
// the first chunk on any inner that reports no length).
|
||||||
|
// * strawcore channel_feed_rss now PROPAGATES the real failure (network /
|
||||||
|
// HTTP / parse) instead of collapsing every error to an empty list, so a
|
||||||
|
// broken channel fetch is distinguishable from "no new videos" (the
|
||||||
|
// bulk subscription_feed keeps its per-channel tolerance).
|
||||||
|
// * Feed recency: a clock-skewed future upload emits "0 seconds ago"
|
||||||
|
// (parses to top) instead of "just now" (which the Kotlin recency parser
|
||||||
|
// couldn't read → sank the item to the bottom).
|
||||||
|
//
|
||||||
// vc=85 / 0.1.0-CS — image caching + SB/RYD → Rust + crash/autoplay fixes:
|
// vc=85 / 0.1.0-CS — image caching + SB/RYD → Rust + crash/autoplay fixes:
|
||||||
// * Thumbnails + channel icons stay cached. Coil's default disk cache is
|
// * Thumbnails + channel icons stay cached. Coil's default disk cache is
|
||||||
// only 2% of the phone's FREE space, so on a storage-tight device the
|
// only 2% of the phone's FREE space, so on a storage-tight device the
|
||||||
|
|
@ -202,6 +225,6 @@ const val STRAW_SDK_TARGET = 35
|
||||||
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
|
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
|
||||||
// strawcore-core (Sulkta-OSS/strawcore) via the UniFFI wrapper; no
|
// strawcore-core (Sulkta-OSS/strawcore) via the UniFFI wrapper; no
|
||||||
// NewPipeExtractor in the runtime path.
|
// NewPipeExtractor in the runtime path.
|
||||||
const val STRAW_VERSION_CODE = 85
|
const val STRAW_VERSION_CODE = 86
|
||||||
const val STRAW_VERSION_NAME = "0.1.0-CS"
|
const val STRAW_VERSION_NAME = "0.1.0-CT"
|
||||||
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,13 @@ pub async fn channel_feed_rss(
|
||||||
crate::runtime::ensure_initialized();
|
crate::runtime::ensure_initialized();
|
||||||
log::info!("strawcore::channel_feed_rss url_len={}", channel_url.len());
|
log::info!("strawcore::channel_feed_rss url_len={}", channel_url.len());
|
||||||
let client = rss_client()?;
|
let client = rss_client()?;
|
||||||
Ok(fetch_channel_rss(client, &channel_url).await.unwrap_or_default())
|
// Propagate the real failure (network / HTTP / parse) instead of
|
||||||
|
// collapsing it to an empty Vec. The single-channel caller (the subs
|
||||||
|
// feed) needs to tell "this channel posted nothing" apart from "this
|
||||||
|
// fetch broke" — the prior `.unwrap_or_default()` made the `Result`
|
||||||
|
// a lie. (subscription_feed keeps its own per-channel unwrap_or_default
|
||||||
|
// below — fan-out tolerance is correct there, not here.)
|
||||||
|
fetch_channel_rss(client, &channel_url).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bulk subscription feed fan-out — for callers that want one round-trip
|
/// Bulk subscription feed fan-out — for callers that want one round-trip
|
||||||
|
|
@ -138,20 +144,34 @@ pub async fn subscription_feed(
|
||||||
Ok(results.into_iter().flatten().collect())
|
Ok(results.into_iter().flatten().collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_channel_rss(client: &Client, channel_url: &str) -> Option<Vec<SearchItem>> {
|
async fn fetch_channel_rss(
|
||||||
let channel_id = extract_channel_id(channel_url)?;
|
client: &Client,
|
||||||
|
channel_url: &str,
|
||||||
|
) -> Result<Vec<SearchItem>, StrawcoreError> {
|
||||||
|
// A URL we can't pull a channel id from isn't a *failure* — it's an
|
||||||
|
// unsupported shape (e.g. an `@handle`, which RSS can't take). Report
|
||||||
|
// "no videos", not an error, so it doesn't trip the caller's retry.
|
||||||
|
let Some(channel_id) = extract_channel_id(channel_url) else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
let url = format!("{RSS_BASE}{channel_id}");
|
let url = format!("{RSS_BASE}{channel_id}");
|
||||||
|
// .without_url() on the error: the rule is to never let a reqwest
|
||||||
|
// error's URL reach a log/message (these RSS URLs carry no secret, but
|
||||||
|
// we keep the discipline uniform).
|
||||||
let resp = client
|
let resp = client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.ok()?
|
.map_err(|e| StrawcoreError::Network { msg: format!("rss fetch: {}", e.without_url()) })?
|
||||||
.error_for_status()
|
.error_for_status()
|
||||||
.ok()?;
|
.map_err(|e| StrawcoreError::Network { msg: format!("rss status: {}", e.without_url()) })?;
|
||||||
// Streaming body read with a hard byte cap — `.text()` reads
|
// Streaming body read with a hard byte cap — `.text()` reads
|
||||||
// unbounded into a String. Shared with the RYD/SB path (net.rs).
|
// unbounded into a String. Shared with the RYD/SB path (net.rs).
|
||||||
let body = crate::net::read_capped_body(resp, RSS_MAX_BYTES).await?;
|
let body = crate::net::read_capped_body(resp, RSS_MAX_BYTES)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| StrawcoreError::Network { msg: "rss body exceeded cap".into() })?;
|
||||||
parse_rss(&body, channel_id)
|
parse_rss(&body, channel_id)
|
||||||
|
.ok_or_else(|| StrawcoreError::Extractor { msg: "rss parse failed".into() })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract the `UCxxx` channel ID from a channel URL. Accepts the
|
/// Extract the `UCxxx` channel ID from a channel URL. Accepts the
|
||||||
|
|
@ -403,7 +423,13 @@ fn iso_to_relative(iso: &str) -> String {
|
||||||
// a single skewed item doesn't pin itself at the top of the
|
// a single skewed item doesn't pin itself at the top of the
|
||||||
// feed.
|
// feed.
|
||||||
if secs > now_secs {
|
if secs > now_secs {
|
||||||
return "just now".to_string();
|
// "0 seconds ago", NOT "just now": the Kotlin recencyScore parser
|
||||||
|
// only matches `(\d+)\s+unit(s)?\s+ago`, so "just now" fails to
|
||||||
|
// parse → Long.MIN_VALUE → the item sinks to the BOTTOM of the feed
|
||||||
|
// (the opposite of intent — a fresh upload on a forward-skewed clock
|
||||||
|
// would vanish to the end). "0 seconds ago" parses to score 0 = top,
|
||||||
|
// and matches what format_relative(0) emits for a now-ish timestamp.
|
||||||
|
return "0 seconds ago".to_string();
|
||||||
}
|
}
|
||||||
format_relative(now_secs - secs)
|
format_relative(now_secs - secs)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -184,7 +184,7 @@ fun ChannelScreen(
|
||||||
}
|
}
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
items(filteredVideos) { item ->
|
items(filteredVideos, key = { it.url }) { item ->
|
||||||
ChannelVideoRow(
|
ChannelVideoRow(
|
||||||
item = item,
|
item = item,
|
||||||
onClick = { onOpenVideo(item.url, item.title) },
|
onClick = { onOpenVideo(item.url, item.title) },
|
||||||
|
|
|
||||||
|
|
@ -327,7 +327,10 @@ fun VideoDetailBody(
|
||||||
uploaderUrl = d.uploaderUrl,
|
uploaderUrl = d.uploaderUrl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
c.trackSelectionParameters = TrackSelectionParameters.Builder(context)
|
// buildUpon() preserves the existing
|
||||||
|
// max-resolution cap; a fresh Builder
|
||||||
|
// would wipe it (data-saver leak).
|
||||||
|
c.trackSelectionParameters = c.trackSelectionParameters.buildUpon()
|
||||||
.setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true)
|
.setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true)
|
||||||
.build()
|
.build()
|
||||||
if (!c.isPlaying) c.play()
|
if (!c.isPlaying) c.play()
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,16 @@ class SubscriptionFeedViewModel : ViewModel() {
|
||||||
val entry = channelCache[ch.url]
|
val entry = channelCache[ch.url]
|
||||||
entry == null || now - entry.fetchedAt >= perChannelTtlMs
|
entry == null || now - entry.fetchedAt >= perChannelTtlMs
|
||||||
}
|
}
|
||||||
if (anyStale || _ui.value.items.isEmpty()) refreshInternal(force = false)
|
if (anyStale || _ui.value.items.isEmpty()) {
|
||||||
|
refreshInternal(force = false)
|
||||||
|
} else {
|
||||||
|
// Everything is fresh and already on screen (warm restart within
|
||||||
|
// the per-channel TTL) — clear the initial loading=true seed so
|
||||||
|
// the Refresh button doesn't sit at "..." forever. The
|
||||||
|
// full-screen spinner is gated on empty items so it never showed;
|
||||||
|
// only StrawHome's button label ("..." vs "Refresh") was stuck.
|
||||||
|
_ui.update { it.copy(loading = false) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh() = refreshInternal(force = true)
|
fun refresh() = refreshInternal(force = true)
|
||||||
|
|
|
||||||
|
|
@ -234,7 +234,13 @@ fun PlayerScreen(
|
||||||
desc = if (audioOnly) "Audio-only on" else "Video on",
|
desc = if (audioOnly) "Audio-only on" else "Video on",
|
||||||
) {
|
) {
|
||||||
audioOnly = !audioOnly
|
audioOnly = !audioOnly
|
||||||
controller.trackSelectionParameters = TrackSelectionParameters.Builder(context)
|
// buildUpon(), NOT a fresh Builder(context) — a new
|
||||||
|
// builder discards every existing constraint, most
|
||||||
|
// importantly the user's max-resolution cap from
|
||||||
|
// setPlayingFrom/applyMaxResolutionCap, so toggling
|
||||||
|
// audio-only off then streamed UNCAPPED resolution
|
||||||
|
// until the next setPlayingFrom (data-saver leak).
|
||||||
|
controller.trackSelectionParameters = controller.trackSelectionParameters.buildUpon()
|
||||||
.setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, audioOnly)
|
.setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, audioOnly)
|
||||||
.build()
|
.build()
|
||||||
Toast.makeText(
|
Toast.makeText(
|
||||||
|
|
|
||||||
|
|
@ -191,7 +191,7 @@ fun SearchScreen(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = rememberBottomContentPadding(),
|
contentPadding = rememberBottomContentPadding(),
|
||||||
) {
|
) {
|
||||||
items(filteredResults) { item ->
|
items(filteredResults, key = { it.url }) { item ->
|
||||||
ResultRow(
|
ResultRow(
|
||||||
item = item,
|
item = item,
|
||||||
onClick = { onOpenVideo(item.url, item.title) },
|
onClick = { onOpenVideo(item.url, item.title) },
|
||||||
|
|
|
||||||
|
|
@ -129,9 +129,14 @@ class IosSafeHttpDataSource(
|
||||||
if (chunkRemaining > 0L) chunkRemaining -= read.toLong()
|
if (chunkRemaining > 0L) chunkRemaining -= read.toLong()
|
||||||
// If chunkRemaining hits 0 here, the next read() call will roll
|
// If chunkRemaining hits 0 here, the next read() call will roll
|
||||||
// to the next chunk via the block at the top.
|
// to the next chunk via the block at the top.
|
||||||
} else if (chunkRemaining > 0L) {
|
} else if (chunkRemaining != 0L) {
|
||||||
// Inner ran out before its advertised end. Force chunk roll on
|
// Inner hit EOF. Force a chunk roll on the next read() so we
|
||||||
// next read() so we re-open at the next position.
|
// re-open at the next position. `!= 0L` (not `> 0L`) so the
|
||||||
|
// unknown-end case (LENGTH_UNSET inner → chunkRemaining < 0)
|
||||||
|
// ALSO rolls: with `> 0L` a negative chunkRemaining never reset
|
||||||
|
// to 0, so the top-of-read() roll block (gated on `== 0L`) never
|
||||||
|
// fired and we re-read the exhausted inner forever — playback
|
||||||
|
// truncated to the first chunk.
|
||||||
chunkRemaining = 0L
|
chunkRemaining = 0L
|
||||||
}
|
}
|
||||||
return read
|
return read
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue