diff --git a/strawApp/src/main/AndroidManifest.xml b/strawApp/src/main/AndroidManifest.xml index 7c9b0c3a4..f7e491cdb 100644 --- a/strawApp/src/main/AndroidManifest.xml +++ b/strawApp/src/main/AndroidManifest.xml @@ -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"> { 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 { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt index e76e91943..dfee148ab 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt @@ -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), diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt index 5482162dc..8ed488e48 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt @@ -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() } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt index 8d9d421ab..a35a27130 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt @@ -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> = _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() } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt index a0a93c0ae..3a9fc8162 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt @@ -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) } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/extractor/NewPipeDownloader.kt b/strawApp/src/main/kotlin/com/sulkta/straw/extractor/NewPipeDownloader.kt index 65b86d220..bc185b5e0 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/extractor/NewPipeDownloader.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/extractor/NewPipeDownloader.kt @@ -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) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt index 18a256dc2..6b4eafcf8 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt @@ -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" -} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt index 1b8c84fbb..837913df0 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt @@ -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 = 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 = if (videosTab != null) { withContext(Dispatchers.IO) { runCatching { - ChannelTabInfo.getInfo(service, firstTab) + ChannelTabInfo.getInfo(service, videosTab) .relatedItems .filterIsInstance() .map { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 5346c6005..176b62bfa 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -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" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt index fd753da3e..90e560a93 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt @@ -46,9 +46,12 @@ class VideoDetailViewModel : ViewModel() { val ui: StateFlow = _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 { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt index cfdd49988..4a2413171 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt @@ -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() } 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, 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, + posSec: Double, + skipped: Set, +): 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 +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt index d72371aea..9801814f3 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt @@ -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" -} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/net/Http.kt b/strawApp/src/main/kotlin/com/sulkta/straw/net/Http.kt new file mode 100644 index 000000000..4a3524a61 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/net/Http.kt @@ -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 diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt b/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt index 7c30dacd4..7c684b4ac 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt @@ -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(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() } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt b/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt index c8b524905..a5bd4b555 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt @@ -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>(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 = - items.joinToString(",", prefix = "[", postfix = "]") { "\"$it\"" } + json.encodeToString(items) private fun sha256Hex(s: String): String { val bytes = MessageDigest.getInstance("SHA-256").digest(s.toByteArray()) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/util/Formatting.kt b/strawApp/src/main/kotlin/com/sulkta/straw/util/Formatting.kt new file mode 100644 index 000000000..e021f7aeb --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/util/Formatting.kt @@ -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) +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/util/Log.kt b/strawApp/src/main/kotlin/com/sulkta/straw/util/Log.kt new file mode 100644 index 000000000..318d98089 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/util/Log.kt @@ -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) +}