Straw audit-fix sprint: CRIT-1 + HIGH-9 + targeted MED

Phase L. Triages findings from the Opus max-effort audit
(memory/2026-05-23-night3-straw later; agent report inline).

CRIT-1: ACTION_SEND URL extracted from arbitrary text/plain share now
  re-validated via URI host check before reaching NewPipeExtractor.
  Manifest scheme also validated for VIEW. Regex broadened to accept
  music.youtube.com + youtube-nocookie. Host set expanded.

HIGH: usesCleartextTraffic="true" removed from manifest (was redundant
  with network_security_config + misleading to reviewers).

HIGH: NewPipeDownloader hardens header copy — runCatching around addHeader
  to survive poisoned response headers with \r/\n. Explicit UA added AFTER
  upstream header copy so it wins. Switched to OkHttp 5 extension form
  for toRequestBody.

HIGH: Bounded response bodies via new util `cappedString(maxBytes)`. Caps
  at 8MiB (NewPipeExtractor), 1MiB (SponsorBlock), 256KiB (RYD). Defends
  against OOM via gigabyte response. Partial defense — chunked transfers
  without Content-Length still streamed up to cap.

HIGH: HistoryStore / SettingsStore / SubscriptionsStore moved from
  non-atomic `_flow.value = next` to `_flow.updateAndGet { ... }`. Closes
  the read-modify-write race that could drop concurrent writes.

HIGH: VideoDetailViewModel.load had a dead-code `if (...)` block with no
  body — falls through every call. Replaced with a real early-return on
  detail-already-loaded.

HIGH: ChannelViewModel picked `info.tabs.firstOrNull()` which is YouTube's
  curated "Home" tab. Switched to `firstOrNull { ChannelTabs.VIDEOS in
  contentFilters }` with fallback. Channel screen now shows actual videos.

HIGH: PlayerScreen SponsorBlock skip loop hardened:
  - dedup skipped UUIDs so re-listen doesn't fight the user
  - poll 250ms → 150ms reduces sponsor leak through buffering
  - `isPlaying` → `playbackState != IDLE/ENDED` so buffering doesn't miss
  - clamp seek away from duration boundary (prevents past-end jank)
  - filter POI-style point segments (start ≈ end)

MED: ExoPlayer pause on app background via Lifecycle.Event.ON_STOP
  observer. Was silently playing audio with no MediaSession when app
  was backgrounded.

MED: Log.d/Log.w gated behind BuildConfig.DEBUG via new strawLogD/strawLogW
  inline helpers in util/Log.kt. Lambda body skipped in release too.
  Log.i remains unguarded for user-quotable events. RydClient +
  SponsorBlockClient + PlayerScreen updated.

MED: Hardcoded version "v0.1.0" in StrawHome replaced with
  BuildConfig.VERSION_NAME. Now reads "v0.1.0-day1" matching ProjectConfig.

MED: Triplicated formatCount/formatViews/formatDuration extracted to
  util/Formatting.kt. Search/Channel/VideoDetail import the shared
  functions.

MED: SponsorBlockClient.buildJsonArray uses kotlinx-serialization Json
  encoder instead of hand-rolled string concat — defends against future
  user-typed category names breaking the URL.

MED: Description regex passes capped to 20k input chars before stripHtml
  on detail screen — defends against ANR on multi-MB descriptions.

Deferred to phase M (not in this sprint):
  - R8 + ProGuard rules for release builds (isMinifyEnabled=true)
  - ExoPlayer hoisting into PlayerViewModel (AndroidViewModel)
  - DI / Koin to replace `error("not initialized")` singleton accessors
  - String resources / i18n
  - rememberSaveable nav stack for process-death restore
  - onNewIntent override for in-running YouTube URL shares
  - certificate pinning on RYD endpoint
This commit is contained in:
Kayos 2026-05-23 20:23:34 -07:00
parent 01496c647a
commit 2fd439cac8
18 changed files with 270 additions and 102 deletions

View file

@ -10,7 +10,6 @@
android:icon="@android:drawable/sym_def_app_icon"
android:roundIcon="@android:drawable/sym_def_app_icon"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity

View file

@ -25,8 +25,14 @@ import com.sulkta.straw.feature.player.PlayerScreen
import com.sulkta.straw.feature.search.SearchScreen
import com.sulkta.straw.feature.settings.SettingsScreen
private val YT_HOSTS = setOf("youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be")
private val YT_URL_RE = Regex("https?://(?:www\\.|m\\.)?(?:youtube\\.com/[^\\s]+|youtu\\.be/[A-Za-z0-9_-]+)")
private val YT_HOSTS = setOf(
"youtube.com", "www.youtube.com", "m.youtube.com",
"music.youtube.com", "youtube-nocookie.com", "www.youtube-nocookie.com",
"youtu.be",
)
private val YT_URL_RE = Regex(
"https?://(?:www\\.|m\\.|music\\.)?(?:youtube(?:-nocookie)?\\.com/[A-Za-z0-9_/?=&\\-.%]+|youtu\\.be/[A-Za-z0-9_\\-]+)",
)
class StrawActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@ -103,18 +109,28 @@ class StrawActivity : ComponentActivity() {
/** Pull a YouTube URL out of an incoming Intent (VIEW or SEND). */
private fun pickYouTubeUrl(intent: Intent?): String? {
intent ?: return null
when (intent.action) {
return when (intent.action) {
Intent.ACTION_VIEW -> {
val data = intent.data?.toString() ?: return null
if (looksLikeYouTube(data)) return data
// Explicit scheme + host check — defense in depth vs the
// manifest intent-filter (apps can synth intents that
// bypass filter scheme matching when activity is exported).
if (intent.scheme?.lowercase() !in setOf("https", "http")) return null
if (!looksLikeYouTube(data)) return null
data
}
Intent.ACTION_SEND -> {
val shared = intent.getStringExtra(Intent.EXTRA_TEXT) ?: return null
val match = YT_URL_RE.find(shared) ?: return null
return match.value
// Regex extracts a YT-looking substring from arbitrary
// attacker-controlled text. Re-validate via URI parse + host
// check before we hand it to NewPipeExtractor.
val candidate = YT_URL_RE.find(shared)?.value ?: return null
val truncated = candidate.substringBefore('#').trim()
if (!looksLikeYouTube(truncated)) return null
truncated
}
else -> null
}
return null
}
private fun looksLikeYouTube(url: String): Boolean {

View file

@ -39,6 +39,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.sulkta.straw.BuildConfig
import com.sulkta.straw.data.ChannelRef
import com.sulkta.straw.data.History
import com.sulkta.straw.data.Subscriptions
@ -70,7 +71,7 @@ fun StrawHome(
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "v0.1.0",
text = "v${BuildConfig.VERSION_NAME}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),

View file

@ -14,6 +14,7 @@ import android.content.SharedPreferences
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.updateAndGet
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@ -45,18 +46,23 @@ class HistoryStore(context: Context) {
fun recordWatch(item: WatchHistoryItem) {
val now = item.copy(watchedAt = System.currentTimeMillis())
val without = _watches.value.filterNot { it.videoId == item.videoId }
val next = (listOf(now) + without).take(MAX_WATCHES)
_watches.value = next
// Atomic read-modify-write via StateFlow.updateAndGet — fixes
// AUD-HIGH race where two concurrent recordWatch calls would
// each read the old list and one would clobber the other.
val next = _watches.updateAndGet { current ->
val without = current.filterNot { it.videoId == item.videoId }
(listOf(now) + without).take(MAX_WATCHES)
}
sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply()
}
fun recordSearch(query: String) {
val q = query.trim()
if (q.isEmpty()) return
val without = _searches.value.filterNot { it.equals(q, ignoreCase = true) }
val next = (listOf(q) + without).take(MAX_SEARCHES)
_searches.value = next
val next = _searches.updateAndGet { current ->
val without = current.filterNot { it.equals(q, ignoreCase = true) }
(listOf(q) + without).take(MAX_SEARCHES)
}
sp.edit().putString(KEY_SEARCHES, json.encodeToString(next)).apply()
}

View file

@ -15,6 +15,7 @@ import android.content.SharedPreferences
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.updateAndGet
/** SponsorBlock category keys — must match server-side IDs. */
enum class SbCategory(val key: String, val label: String, val help: String) {
@ -37,9 +38,10 @@ class SettingsStore(context: Context) {
val sbCategories: StateFlow<Set<SbCategory>> = _sbCategories.asStateFlow()
fun toggle(cat: SbCategory) {
val cur = _sbCategories.value
val next = if (cat in cur) cur - cat else cur + cat
_sbCategories.value = next
// Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore.
val next = _sbCategories.updateAndGet { cur ->
if (cat in cur) cur - cat else cur + cat
}
sp.edit().putStringSet(KEY_SB_CATS, next.map { it.key }.toSet()).apply()
}

View file

@ -13,6 +13,7 @@ import android.content.SharedPreferences
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.updateAndGet
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@ -37,13 +38,14 @@ class SubscriptionsStore(context: Context) {
_subs.value.any { it.url == channelUrl }
fun toggle(ref: ChannelRef) {
val cur = _subs.value
val next = if (cur.any { it.url == ref.url }) {
cur.filterNot { it.url == ref.url }
} else {
cur + ref
// Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore.
val next = _subs.updateAndGet { cur ->
if (cur.any { it.url == ref.url }) {
cur.filterNot { it.url == ref.url }
} else {
cur + ref
}
}
_subs.value = next
persist(next)
}

View file

@ -9,8 +9,10 @@
package com.sulkta.straw.extractor
import com.sulkta.straw.net.NEWPIPE_MAX_BYTES
import com.sulkta.straw.net.cappedString
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.schabi.newpipe.extractor.downloader.Downloader
import org.schabi.newpipe.extractor.downloader.Request
import org.schabi.newpipe.extractor.downloader.Response
@ -27,23 +29,30 @@ class NewPipeDownloader private constructor(
val headers = request.headers()
val data: ByteArray? = request.dataToSend()
val requestBody: RequestBody? = if (data != null) {
RequestBody.create(null, data)
} else null
val requestBody = data?.toRequestBody(null)
val okBuilder = okhttp3.Request.Builder()
.method(httpMethod, requestBody)
.url(url)
.addHeader("User-Agent", USER_AGENT)
// AUD-HIGH: copy NPE headers BEFORE adding our explicit UA so the
// explicit UA wins; guard against header values containing \r/\n
// which OkHttp's addHeader rejects via IAE (turning a poisoned
// response into an app crash).
headers.forEach { (name, values) ->
if (name.equals("User-Agent", ignoreCase = true)) return@forEach
okBuilder.removeHeader(name)
values.forEach { okBuilder.addHeader(name, it) }
values.forEach { value ->
runCatching { okBuilder.addHeader(name, value) }
}
}
okBuilder.removeHeader("User-Agent")
okBuilder.addHeader("User-Agent", USER_AGENT)
val okResponse = client.newCall(okBuilder.build()).execute()
val body = okResponse.body
val bodyString = body?.string() ?: ""
// AUD-HIGH: bounded read to defend against OOM via gigabyte response.
val bodyString = body?.cappedString(NEWPIPE_MAX_BYTES) ?: ""
val responseHeaders = okResponse.headers.toMultimap()
val latestUrl = okResponse.request.url.toString()
if (okResponse.code == 429) {

View file

@ -46,6 +46,8 @@ import coil3.compose.AsyncImage
import com.sulkta.straw.data.ChannelRef
import com.sulkta.straw.data.Subscriptions
import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.formatCount
import com.sulkta.straw.util.formatDuration
@Composable
fun ChannelScreen(
@ -177,16 +179,3 @@ private fun ChannelVideoRow(item: StreamItem, onClick: () -> Unit) {
}
}
private fun formatDuration(sec: Long): String {
val h = sec / 3600
val m = (sec % 3600) / 60
val s = sec % 60
return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s)
}
private fun formatCount(n: Long): String = when {
n >= 1_000_000_000 -> "%.1fB".format(n / 1_000_000_000.0)
n >= 1_000_000 -> "%.1fM".format(n / 1_000_000.0)
n >= 1_000 -> "%.1fK".format(n / 1_000.0)
else -> "$n"
}

View file

@ -18,6 +18,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.schabi.newpipe.extractor.channel.ChannelInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs
import org.schabi.newpipe.extractor.stream.StreamInfoItem
data class ChannelUiState(
@ -42,11 +43,16 @@ class ChannelViewModel : ViewModel() {
val info = withContext(Dispatchers.IO) {
ChannelInfo.getInfo(service, channelUrl)
}
val firstTab = info.tabs.firstOrNull()
val videos: List<StreamItem> = if (firstTab != null) {
// AUD-HIGH: pick the Videos tab specifically rather than
// info.tabs.firstOrNull() which is YouTube's "Home" (a
// curated mix that mostly drops via filterIsInstance).
val videosTab = info.tabs.firstOrNull {
it.contentFilters.contains(ChannelTabs.VIDEOS)
} ?: info.tabs.firstOrNull()
val videos: List<StreamItem> = if (videosTab != null) {
withContext(Dispatchers.IO) {
runCatching {
ChannelTabInfo.getInfo(service, firstTab)
ChannelTabInfo.getInfo(service, videosTab)
.relatedItems
.filterIsInstance<StreamInfoItem>()
.map {

View file

@ -38,6 +38,8 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import com.sulkta.straw.util.formatCount
import com.sulkta.straw.util.formatViews
import com.sulkta.straw.util.stripHtml
@Composable
@ -135,8 +137,10 @@ fun VideoDetailScreen(
Text("Description", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.height(8.dp))
// AUD-MED: cap input length before regex passes — defends
// against ANR on multi-MB descriptions.
Text(
text = stripHtml(d.description).take(2000),
text = stripHtml(d.description.take(20_000)).take(2000),
style = MaterialTheme.typography.bodySmall,
)
}
@ -144,11 +148,3 @@ fun VideoDetailScreen(
}
}
private fun formatCount(n: Long): String = when {
n >= 1_000_000_000 -> "%.1fB".format(n / 1_000_000_000.0)
n >= 1_000_000 -> "%.1fM".format(n / 1_000_000.0)
n >= 1_000 -> "%.1fK".format(n / 1_000.0)
else -> "$n"
}
private fun formatViews(v: Long): String = "${formatCount(v)} views"

View file

@ -46,9 +46,12 @@ class VideoDetailViewModel : ViewModel() {
val ui: StateFlow<VideoDetailUiState> = _ui.asStateFlow()
fun load(streamUrl: String) {
if (_ui.value.detail != null || _ui.value.loading.not()) {
// Already loaded or already loading once
}
// AUD-HIGH: previous guard was a dead-code if-block. The
// LaunchedEffect(streamUrl) caller only fires once per key + a new
// ViewModel is constructed for each nav entry, so the guard isn't
// strictly needed — but a real one is cheap insurance against
// future callers.
if (_ui.value.detail != null) return
_ui.value = VideoDetailUiState(loading = true)
viewModelScope.launch {
try {

View file

@ -8,7 +8,6 @@
package com.sulkta.straw.feature.player
import android.util.Log
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.compose.foundation.background
@ -31,9 +30,13 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
@ -44,6 +47,7 @@ import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.ui.PlayerView
import com.sulkta.straw.extractor.NewPipeDownloader
import com.sulkta.straw.net.SbSegment
import com.sulkta.straw.util.strawLogI
import kotlinx.coroutines.delay
@OptIn(UnstableApi::class)
@ -65,6 +69,18 @@ fun PlayerScreen(
onDispose { exoPlayer.release() }
}
// AUD-MED: pause playback when app goes to background. Without this,
// ExoPlayer keeps playing audio with no MediaSession — user can't pause
// from the notification shade.
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) exoPlayer.pause()
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
val resolved = state.resolved
LaunchedEffect(resolved) {
@ -104,24 +120,38 @@ fun PlayerScreen(
}
}
// SponsorBlock auto-skip — poll position every 250ms, seek past any segment.
// SponsorBlock auto-skip — poll position every 150ms, seek past any segment.
// AUD-HIGH fixes vs initial impl:
// - dedup skipped segments via UUID so re-listen doesn't fight the user
// - tighter poll (150ms) reduces sponsor leak through buffering window
// - check playbackState != IDLE/ENDED (was isPlaying, which is false
// during buffering and missed the skip window)
// - clamp seek target away from duration boundary to avoid jank
val skippedUuids = remember { mutableSetOf<String>() }
LaunchedEffect(resolved?.segments) {
val segments = resolved?.segments ?: return@LaunchedEffect
if (segments.isEmpty()) return@LaunchedEffect
skippedUuids.clear()
while (true) {
delay(250)
if (!exoPlayer.isPlaying) continue
delay(150)
val state = exoPlayer.playbackState
if (state == Player.STATE_IDLE || state == Player.STATE_ENDED) continue
val posSec = exoPlayer.currentPosition / 1000.0
val segment = pickActiveSegment(segments, posSec)
if (segment != null) {
Log.i("StrawSb", "skip: ${segment.category} ${segment.startSec}s..${segment.endSec}s (pos=$posSec)")
exoPlayer.seekTo((segment.endSec * 1000).toLong())
Toast.makeText(
context,
"skipped ${segment.category}",
Toast.LENGTH_SHORT,
).show()
val segment = pickActiveSegment(segments, posSec, skippedUuids) ?: continue
strawLogI(
"StrawSb",
"skip: ${segment.category} ${segment.startSec}s..${segment.endSec}s (pos=$posSec)",
)
val targetMs = (segment.endSec * 1000).toLong()
val durationMs = exoPlayer.duration
if (durationMs > 0 && targetMs >= durationMs - 500) {
// Past end — let it end naturally rather than seeking past content.
exoPlayer.seekTo(durationMs - 1)
} else {
exoPlayer.seekTo(targetMs)
}
segment.UUID?.let { skippedUuids.add(it) }
Toast.makeText(context, "skipped ${segment.category}", Toast.LENGTH_SHORT).show()
}
}
@ -175,6 +205,16 @@ fun PlayerScreen(
}
}
/** Returns the segment whose interval contains [posSec], if any. */
private fun pickActiveSegment(segments: List<SbSegment>, posSec: Double): SbSegment? =
segments.firstOrNull { posSec >= it.startSec && posSec < it.endSec }
/**
* Returns the segment whose interval contains [posSec], if any, skipping
* UUIDs in [skipped]. Filters out POI-style point segments (start == end).
*/
private fun pickActiveSegment(
segments: List<SbSegment>,
posSec: Double,
skipped: Set<String>,
): SbSegment? = segments.firstOrNull { s ->
val uuidNotSkipped = s.UUID == null || s.UUID !in skipped
val interval = s.endSec - s.startSec > 0.1
uuidNotSkipped && interval && posSec >= s.startSec && posSec < s.endSec - 0.05
}

View file

@ -43,6 +43,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import com.sulkta.straw.data.History
import com.sulkta.straw.util.formatDuration
import com.sulkta.straw.util.formatViews
@Composable
fun SearchScreen(
@ -171,16 +173,3 @@ private fun ResultRow(item: StreamItem, onClick: () -> Unit) {
}
}
private fun formatDuration(sec: Long): String {
val h = sec / 3600
val m = (sec % 3600) / 60
val s = sec % 60
return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s)
}
private fun formatViews(views: Long): String = when {
views >= 1_000_000_000 -> "%.1fB views".format(views / 1_000_000_000.0)
views >= 1_000_000 -> "%.1fM views".format(views / 1_000_000.0)
views >= 1_000 -> "%.1fK views".format(views / 1_000.0)
else -> "$views views"
}

View file

@ -0,0 +1,47 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Bounded body reader. AUD-HIGH flagged that body.string() reads the whole
* response into memory with no cap a hostile or compromised endpoint
* (RYD, SponsorBlock, even a poisoned YT response) can OOM the app.
*
* Partial defense: if Content-Length is advertised and over the cap, we
* refuse the read. If Content-Length is absent (chunked transfer), we
* stream up to the cap and refuse if the body keeps coming.
*/
package com.sulkta.straw.net
import okhttp3.ResponseBody
import okio.Buffer
import java.io.IOException
fun ResponseBody.cappedString(maxBytes: Long): String {
val cl = contentLength()
if (cl in 1..maxBytes) {
return string()
}
if (cl > maxBytes) {
close()
throw IOException("response too large: $cl bytes > $maxBytes cap")
}
// Chunked transfer or no content-length advertised — stream up to cap.
val src = source()
val buf = Buffer()
var read = 0L
while (read < maxBytes) {
val chunk = src.read(buf, maxBytes - read)
if (chunk == -1L) break
read += chunk
}
if (!src.exhausted()) {
close()
throw IOException("response exceeded cap of $maxBytes bytes")
}
return buf.readUtf8()
}
const val NEWPIPE_MAX_BYTES = 8L * 1024 * 1024
const val RYD_MAX_BYTES = 256L * 1024
const val SB_MAX_BYTES = 1L * 1024 * 1024

View file

@ -8,8 +8,9 @@
package com.sulkta.straw.net
import android.util.Log
import com.sulkta.straw.extractor.NewPipeDownloader
import com.sulkta.straw.util.strawLogD
import com.sulkta.straw.util.strawLogW
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.Request
@ -30,7 +31,7 @@ object RydClient {
/** Blocking — call from Dispatchers.IO. */
fun fetch(videoId: String): RydVotes? {
val url = "https://returnyoutubedislikeapi.com/votes?videoId=$videoId"
Log.d(TAG, "fetch start: $videoId$url")
strawLogD(TAG) { "fetch start: $videoId$url" }
val req = Request.Builder()
.url(url)
.header("User-Agent", NewPipeDownloader.USER_AGENT)
@ -39,14 +40,15 @@ object RydClient {
return runCatching {
NewPipeDownloader.client().newCall(req).execute().use { r ->
val code = r.code
val bodyStr = r.body?.string() ?: ""
Log.d(TAG, "response: code=$code, body[0..120]=${bodyStr.take(120)}")
// AUD-HIGH: bounded body read to defend against OOM.
val bodyStr = r.body?.cappedString(RYD_MAX_BYTES) ?: ""
strawLogD(TAG) { "response: code=$code, body[0..120]=${bodyStr.take(120)}" }
if (!r.isSuccessful) return@use null
runCatching { json.decodeFromString<RydVotes>(bodyStr) }
.onFailure { Log.w(TAG, "json decode failed: ${it.message}") }
.onFailure { strawLogW(TAG) { "json decode failed: ${it.message}" } }
.getOrNull()
}
}.onFailure { Log.w(TAG, "fetch failed: ${it.javaClass.simpleName}: ${it.message}") }
}.onFailure { strawLogW(TAG) { "fetch failed: ${it.javaClass.simpleName}: ${it.message}" } }
.getOrNull()
}
}

View file

@ -8,8 +8,9 @@
package com.sulkta.straw.net
import android.util.Log
import com.sulkta.straw.extractor.NewPipeDownloader
import com.sulkta.straw.util.strawLogD
import com.sulkta.straw.util.strawLogW
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.Request
@ -43,7 +44,7 @@ object SponsorBlockClient {
val prefix = sha256Hex(videoId).substring(0, 4)
val urlStr = "https://sponsor.ajay.app/api/skipSegments/$prefix?" +
"categories=" + buildJsonArray(categories)
Log.d(TAG, "fetch: videoId=$videoId prefix=$prefix url=$urlStr")
strawLogD(TAG) { "fetch: videoId=$videoId prefix=$prefix url=$urlStr" }
val req = Request.Builder()
.url(urlStr)
.header("User-Agent", NewPipeDownloader.USER_AGENT)
@ -52,23 +53,28 @@ object SponsorBlockClient {
return runCatching {
NewPipeDownloader.client().newCall(req).execute().use { r ->
val code = r.code
val bodyStr = r.body?.string() ?: ""
Log.d(TAG, "response: code=$code body_len=${bodyStr.length}")
// AUD-HIGH: bounded body read.
val bodyStr = r.body?.cappedString(SB_MAX_BYTES) ?: ""
strawLogD(TAG) { "response: code=$code body_len=${bodyStr.length}" }
if (!r.isSuccessful) return@use emptyList()
val all = runCatching {
json.decodeFromString<List<SbVideoSegments>>(bodyStr)
}.onFailure { Log.w(TAG, "json decode failed: ${it.message}") }
}.onFailure { strawLogW(TAG) { "json decode failed: ${it.message}" } }
.getOrDefault(emptyList())
val mine = all.firstOrNull { it.videoID == videoId }?.segments.orEmpty()
Log.d(TAG, "armed ${mine.size} segments for $videoId (response had ${all.size} matching-prefix videos)")
strawLogD(TAG) { "armed ${mine.size} segments for $videoId (response had ${all.size} matching-prefix videos)" }
mine
}
}.onFailure { Log.w(TAG, "fetch failed: ${it.javaClass.simpleName}: ${it.message}") }
}.onFailure { strawLogW(TAG) { "fetch failed: ${it.javaClass.simpleName}: ${it.message}" } }
.getOrDefault(emptyList())
}
/**
* AUD-MED: encode via kotlinx-serialization rather than string concat;
* defends against future user-typed category names breaking the URL.
*/
private fun buildJsonArray(items: List<String>): String =
items.joinToString(",", prefix = "[", postfix = "]") { "\"$it\"" }
json.encodeToString(items)
private fun sha256Hex(s: String): String {
val bytes = MessageDigest.getInstance("SHA-256").digest(s.toByteArray())

View file

@ -0,0 +1,27 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Shared number/duration formatters. AUD-MED noted three near-identical
* copies of formatCount/formatViews/formatDuration in Search, Channel,
* VideoDetail screens. Consolidated here.
*/
package com.sulkta.straw.util
fun formatCount(n: Long): String = when {
n >= 1_000_000_000 -> "%.1fB".format(n / 1_000_000_000.0)
n >= 1_000_000 -> "%.1fM".format(n / 1_000_000.0)
n >= 1_000 -> "%.1fK".format(n / 1_000.0)
else -> "$n"
}
fun formatViews(n: Long): String = "${formatCount(n)} views"
fun formatDuration(sec: Long): String {
if (sec <= 0) return ""
val h = sec / 3600
val m = (sec % 3600) / 60
val s = sec % 60
return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s)
}

View file

@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Logging helpers that no-op in release builds. Audit AUD-MED found that
* Log.d/Log.w with video IDs + search queries + RYD body prefixes shipped
* unstripped in release because R8 is off. This keeps the call sites
* concise while gating on BuildConfig.DEBUG to skip both the Log call
* AND the lambda body in release.
*/
package com.sulkta.straw.util
import android.util.Log
import com.sulkta.straw.BuildConfig
inline fun strawLogD(tag: String, msg: () -> String) {
if (BuildConfig.DEBUG) Log.d(tag, msg())
}
inline fun strawLogW(tag: String, t: Throwable? = null, msg: () -> String) {
if (BuildConfig.DEBUG) Log.w(tag, msg(), t)
}
/** Always logs — for events the user might quote in a bug report. */
fun strawLogI(tag: String, msg: String) {
Log.i(tag, msg)
}