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:
parent
01496c647a
commit
2fd439cac8
18 changed files with 270 additions and 102 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
47
strawApp/src/main/kotlin/com/sulkta/straw/net/Http.kt
Normal file
47
strawApp/src/main/kotlin/com/sulkta/straw/net/Http.kt
Normal 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
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
27
strawApp/src/main/kotlin/com/sulkta/straw/util/Formatting.kt
Normal file
27
strawApp/src/main/kotlin/com/sulkta/straw/util/Formatting.kt
Normal 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)
|
||||
}
|
||||
28
strawApp/src/main/kotlin/com/sulkta/straw/util/Log.kt
Normal file
28
strawApp/src/main/kotlin/com/sulkta/straw/util/Log.kt
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue