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
9f01a0ae1b
commit
ae8bef117f
18 changed files with 270 additions and 102 deletions
|
|
@ -10,7 +10,6 @@
|
||||||
android:icon="@android:drawable/sym_def_app_icon"
|
android:icon="@android:drawable/sym_def_app_icon"
|
||||||
android:roundIcon="@android:drawable/sym_def_app_icon"
|
android:roundIcon="@android:drawable/sym_def_app_icon"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:usesCleartextTraffic="true"
|
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||||
<activity
|
<activity
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,14 @@ import com.sulkta.straw.feature.player.PlayerScreen
|
||||||
import com.sulkta.straw.feature.search.SearchScreen
|
import com.sulkta.straw.feature.search.SearchScreen
|
||||||
import com.sulkta.straw.feature.settings.SettingsScreen
|
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_HOSTS = setOf(
|
||||||
private val YT_URL_RE = Regex("https?://(?:www\\.|m\\.)?(?:youtube\\.com/[^\\s]+|youtu\\.be/[A-Za-z0-9_-]+)")
|
"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() {
|
class StrawActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
|
@ -103,18 +109,28 @@ class StrawActivity : ComponentActivity() {
|
||||||
/** Pull a YouTube URL out of an incoming Intent (VIEW or SEND). */
|
/** Pull a YouTube URL out of an incoming Intent (VIEW or SEND). */
|
||||||
private fun pickYouTubeUrl(intent: Intent?): String? {
|
private fun pickYouTubeUrl(intent: Intent?): String? {
|
||||||
intent ?: return null
|
intent ?: return null
|
||||||
when (intent.action) {
|
return when (intent.action) {
|
||||||
Intent.ACTION_VIEW -> {
|
Intent.ACTION_VIEW -> {
|
||||||
val data = intent.data?.toString() ?: return null
|
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 -> {
|
Intent.ACTION_SEND -> {
|
||||||
val shared = intent.getStringExtra(Intent.EXTRA_TEXT) ?: return null
|
val shared = intent.getStringExtra(Intent.EXTRA_TEXT) ?: return null
|
||||||
val match = YT_URL_RE.find(shared) ?: return null
|
// Regex extracts a YT-looking substring from arbitrary
|
||||||
return match.value
|
// 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 {
|
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import com.sulkta.straw.BuildConfig
|
||||||
import com.sulkta.straw.data.ChannelRef
|
import com.sulkta.straw.data.ChannelRef
|
||||||
import com.sulkta.straw.data.History
|
import com.sulkta.straw.data.History
|
||||||
import com.sulkta.straw.data.Subscriptions
|
import com.sulkta.straw.data.Subscriptions
|
||||||
|
|
@ -70,7 +71,7 @@ fun StrawHome(
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "v0.1.0",
|
text = "v${BuildConfig.VERSION_NAME}",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import android.content.SharedPreferences
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.updateAndGet
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
|
@ -45,18 +46,23 @@ class HistoryStore(context: Context) {
|
||||||
|
|
||||||
fun recordWatch(item: WatchHistoryItem) {
|
fun recordWatch(item: WatchHistoryItem) {
|
||||||
val now = item.copy(watchedAt = System.currentTimeMillis())
|
val now = item.copy(watchedAt = System.currentTimeMillis())
|
||||||
val without = _watches.value.filterNot { it.videoId == item.videoId }
|
// Atomic read-modify-write via StateFlow.updateAndGet — fixes
|
||||||
val next = (listOf(now) + without).take(MAX_WATCHES)
|
// AUD-HIGH race where two concurrent recordWatch calls would
|
||||||
_watches.value = next
|
// 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()
|
sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun recordSearch(query: String) {
|
fun recordSearch(query: String) {
|
||||||
val q = query.trim()
|
val q = query.trim()
|
||||||
if (q.isEmpty()) return
|
if (q.isEmpty()) return
|
||||||
val without = _searches.value.filterNot { it.equals(q, ignoreCase = true) }
|
val next = _searches.updateAndGet { current ->
|
||||||
val next = (listOf(q) + without).take(MAX_SEARCHES)
|
val without = current.filterNot { it.equals(q, ignoreCase = true) }
|
||||||
_searches.value = next
|
(listOf(q) + without).take(MAX_SEARCHES)
|
||||||
|
}
|
||||||
sp.edit().putString(KEY_SEARCHES, json.encodeToString(next)).apply()
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.updateAndGet
|
||||||
|
|
||||||
/** SponsorBlock category keys — must match server-side IDs. */
|
/** SponsorBlock category keys — must match server-side IDs. */
|
||||||
enum class SbCategory(val key: String, val label: String, val help: String) {
|
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()
|
val sbCategories: StateFlow<Set<SbCategory>> = _sbCategories.asStateFlow()
|
||||||
|
|
||||||
fun toggle(cat: SbCategory) {
|
fun toggle(cat: SbCategory) {
|
||||||
val cur = _sbCategories.value
|
// Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore.
|
||||||
val next = if (cat in cur) cur - cat else cur + cat
|
val next = _sbCategories.updateAndGet { cur ->
|
||||||
_sbCategories.value = next
|
if (cat in cur) cur - cat else cur + cat
|
||||||
|
}
|
||||||
sp.edit().putStringSet(KEY_SB_CATS, next.map { it.key }.toSet()).apply()
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.updateAndGet
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
|
@ -37,13 +38,14 @@ class SubscriptionsStore(context: Context) {
|
||||||
_subs.value.any { it.url == channelUrl }
|
_subs.value.any { it.url == channelUrl }
|
||||||
|
|
||||||
fun toggle(ref: ChannelRef) {
|
fun toggle(ref: ChannelRef) {
|
||||||
val cur = _subs.value
|
// Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore.
|
||||||
val next = if (cur.any { it.url == ref.url }) {
|
val next = _subs.updateAndGet { cur ->
|
||||||
cur.filterNot { it.url == ref.url }
|
if (cur.any { it.url == ref.url }) {
|
||||||
} else {
|
cur.filterNot { it.url == ref.url }
|
||||||
cur + ref
|
} else {
|
||||||
|
cur + ref
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_subs.value = next
|
|
||||||
persist(next)
|
persist(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,10 @@
|
||||||
|
|
||||||
package com.sulkta.straw.extractor
|
package com.sulkta.straw.extractor
|
||||||
|
|
||||||
|
import com.sulkta.straw.net.NEWPIPE_MAX_BYTES
|
||||||
|
import com.sulkta.straw.net.cappedString
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.RequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import org.schabi.newpipe.extractor.downloader.Downloader
|
import org.schabi.newpipe.extractor.downloader.Downloader
|
||||||
import org.schabi.newpipe.extractor.downloader.Request
|
import org.schabi.newpipe.extractor.downloader.Request
|
||||||
import org.schabi.newpipe.extractor.downloader.Response
|
import org.schabi.newpipe.extractor.downloader.Response
|
||||||
|
|
@ -27,23 +29,30 @@ class NewPipeDownloader private constructor(
|
||||||
val headers = request.headers()
|
val headers = request.headers()
|
||||||
val data: ByteArray? = request.dataToSend()
|
val data: ByteArray? = request.dataToSend()
|
||||||
|
|
||||||
val requestBody: RequestBody? = if (data != null) {
|
val requestBody = data?.toRequestBody(null)
|
||||||
RequestBody.create(null, data)
|
|
||||||
} else null
|
|
||||||
|
|
||||||
val okBuilder = okhttp3.Request.Builder()
|
val okBuilder = okhttp3.Request.Builder()
|
||||||
.method(httpMethod, requestBody)
|
.method(httpMethod, requestBody)
|
||||||
.url(url)
|
.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) ->
|
headers.forEach { (name, values) ->
|
||||||
|
if (name.equals("User-Agent", ignoreCase = true)) return@forEach
|
||||||
okBuilder.removeHeader(name)
|
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 okResponse = client.newCall(okBuilder.build()).execute()
|
||||||
val body = okResponse.body
|
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 responseHeaders = okResponse.headers.toMultimap()
|
||||||
val latestUrl = okResponse.request.url.toString()
|
val latestUrl = okResponse.request.url.toString()
|
||||||
if (okResponse.code == 429) {
|
if (okResponse.code == 429) {
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,8 @@ import coil3.compose.AsyncImage
|
||||||
import com.sulkta.straw.data.ChannelRef
|
import com.sulkta.straw.data.ChannelRef
|
||||||
import com.sulkta.straw.data.Subscriptions
|
import com.sulkta.straw.data.Subscriptions
|
||||||
import com.sulkta.straw.feature.search.StreamItem
|
import com.sulkta.straw.feature.search.StreamItem
|
||||||
|
import com.sulkta.straw.util.formatCount
|
||||||
|
import com.sulkta.straw.util.formatDuration
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChannelScreen(
|
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 kotlinx.coroutines.withContext
|
||||||
import org.schabi.newpipe.extractor.channel.ChannelInfo
|
import org.schabi.newpipe.extractor.channel.ChannelInfo
|
||||||
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo
|
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo
|
||||||
|
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||||
|
|
||||||
data class ChannelUiState(
|
data class ChannelUiState(
|
||||||
|
|
@ -42,11 +43,16 @@ class ChannelViewModel : ViewModel() {
|
||||||
val info = withContext(Dispatchers.IO) {
|
val info = withContext(Dispatchers.IO) {
|
||||||
ChannelInfo.getInfo(service, channelUrl)
|
ChannelInfo.getInfo(service, channelUrl)
|
||||||
}
|
}
|
||||||
val firstTab = info.tabs.firstOrNull()
|
// AUD-HIGH: pick the Videos tab specifically rather than
|
||||||
val videos: List<StreamItem> = if (firstTab != null) {
|
// 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) {
|
withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
ChannelTabInfo.getInfo(service, firstTab)
|
ChannelTabInfo.getInfo(service, videosTab)
|
||||||
.relatedItems
|
.relatedItems
|
||||||
.filterIsInstance<StreamInfoItem>()
|
.filterIsInstance<StreamInfoItem>()
|
||||||
.map {
|
.map {
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import com.sulkta.straw.util.formatCount
|
||||||
|
import com.sulkta.straw.util.formatViews
|
||||||
import com.sulkta.straw.util.stripHtml
|
import com.sulkta.straw.util.stripHtml
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -135,8 +137,10 @@ fun VideoDetailScreen(
|
||||||
|
|
||||||
Text("Description", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
Text("Description", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
// AUD-MED: cap input length before regex passes — defends
|
||||||
|
// against ANR on multi-MB descriptions.
|
||||||
Text(
|
Text(
|
||||||
text = stripHtml(d.description).take(2000),
|
text = stripHtml(d.description.take(20_000)).take(2000),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
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()
|
val ui: StateFlow<VideoDetailUiState> = _ui.asStateFlow()
|
||||||
|
|
||||||
fun load(streamUrl: String) {
|
fun load(streamUrl: String) {
|
||||||
if (_ui.value.detail != null || _ui.value.loading.not()) {
|
// AUD-HIGH: previous guard was a dead-code if-block. The
|
||||||
// Already loaded or already loading once
|
// 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)
|
_ui.value = VideoDetailUiState(loading = true)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@
|
||||||
|
|
||||||
package com.sulkta.straw.feature.player
|
package com.sulkta.straw.feature.player
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.compose.foundation.background
|
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.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
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.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.datasource.DefaultHttpDataSource
|
import androidx.media3.datasource.DefaultHttpDataSource
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
|
@ -44,6 +47,7 @@ import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
import com.sulkta.straw.extractor.NewPipeDownloader
|
import com.sulkta.straw.extractor.NewPipeDownloader
|
||||||
import com.sulkta.straw.net.SbSegment
|
import com.sulkta.straw.net.SbSegment
|
||||||
|
import com.sulkta.straw.util.strawLogI
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
|
|
@ -65,6 +69,18 @@ fun PlayerScreen(
|
||||||
onDispose { exoPlayer.release() }
|
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
|
val resolved = state.resolved
|
||||||
|
|
||||||
LaunchedEffect(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) {
|
LaunchedEffect(resolved?.segments) {
|
||||||
val segments = resolved?.segments ?: return@LaunchedEffect
|
val segments = resolved?.segments ?: return@LaunchedEffect
|
||||||
if (segments.isEmpty()) return@LaunchedEffect
|
if (segments.isEmpty()) return@LaunchedEffect
|
||||||
|
skippedUuids.clear()
|
||||||
while (true) {
|
while (true) {
|
||||||
delay(250)
|
delay(150)
|
||||||
if (!exoPlayer.isPlaying) continue
|
val state = exoPlayer.playbackState
|
||||||
|
if (state == Player.STATE_IDLE || state == Player.STATE_ENDED) continue
|
||||||
val posSec = exoPlayer.currentPosition / 1000.0
|
val posSec = exoPlayer.currentPosition / 1000.0
|
||||||
val segment = pickActiveSegment(segments, posSec)
|
val segment = pickActiveSegment(segments, posSec, skippedUuids) ?: continue
|
||||||
if (segment != null) {
|
strawLogI(
|
||||||
Log.i("StrawSb", "skip: ${segment.category} ${segment.startSec}s..${segment.endSec}s (pos=$posSec)")
|
"StrawSb",
|
||||||
exoPlayer.seekTo((segment.endSec * 1000).toLong())
|
"skip: ${segment.category} ${segment.startSec}s..${segment.endSec}s (pos=$posSec)",
|
||||||
Toast.makeText(
|
)
|
||||||
context,
|
val targetMs = (segment.endSec * 1000).toLong()
|
||||||
"skipped ${segment.category}",
|
val durationMs = exoPlayer.duration
|
||||||
Toast.LENGTH_SHORT,
|
if (durationMs > 0 && targetMs >= durationMs - 500) {
|
||||||
).show()
|
// 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? =
|
* Returns the segment whose interval contains [posSec], if any, skipping
|
||||||
segments.firstOrNull { posSec >= it.startSec && posSec < it.endSec }
|
* 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 androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.sulkta.straw.data.History
|
import com.sulkta.straw.data.History
|
||||||
|
import com.sulkta.straw.util.formatDuration
|
||||||
|
import com.sulkta.straw.util.formatViews
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SearchScreen(
|
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
|
||||||
|
* 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
|
package com.sulkta.straw.net
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import com.sulkta.straw.extractor.NewPipeDownloader
|
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.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
|
@ -30,7 +31,7 @@ object RydClient {
|
||||||
/** Blocking — call from Dispatchers.IO. */
|
/** Blocking — call from Dispatchers.IO. */
|
||||||
fun fetch(videoId: String): RydVotes? {
|
fun fetch(videoId: String): RydVotes? {
|
||||||
val url = "https://returnyoutubedislikeapi.com/votes?videoId=$videoId"
|
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()
|
val req = Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
.header("User-Agent", NewPipeDownloader.USER_AGENT)
|
.header("User-Agent", NewPipeDownloader.USER_AGENT)
|
||||||
|
|
@ -39,14 +40,15 @@ object RydClient {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
NewPipeDownloader.client().newCall(req).execute().use { r ->
|
NewPipeDownloader.client().newCall(req).execute().use { r ->
|
||||||
val code = r.code
|
val code = r.code
|
||||||
val bodyStr = r.body?.string() ?: ""
|
// AUD-HIGH: bounded body read to defend against OOM.
|
||||||
Log.d(TAG, "response: code=$code, body[0..120]=${bodyStr.take(120)}")
|
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
|
if (!r.isSuccessful) return@use null
|
||||||
runCatching { json.decodeFromString<RydVotes>(bodyStr) }
|
runCatching { json.decodeFromString<RydVotes>(bodyStr) }
|
||||||
.onFailure { Log.w(TAG, "json decode failed: ${it.message}") }
|
.onFailure { strawLogW(TAG) { "json decode failed: ${it.message}" } }
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
}
|
}
|
||||||
}.onFailure { Log.w(TAG, "fetch failed: ${it.javaClass.simpleName}: ${it.message}") }
|
}.onFailure { strawLogW(TAG) { "fetch failed: ${it.javaClass.simpleName}: ${it.message}" } }
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,9 @@
|
||||||
|
|
||||||
package com.sulkta.straw.net
|
package com.sulkta.straw.net
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import com.sulkta.straw.extractor.NewPipeDownloader
|
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.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
|
@ -43,7 +44,7 @@ object SponsorBlockClient {
|
||||||
val prefix = sha256Hex(videoId).substring(0, 4)
|
val prefix = sha256Hex(videoId).substring(0, 4)
|
||||||
val urlStr = "https://sponsor.ajay.app/api/skipSegments/$prefix?" +
|
val urlStr = "https://sponsor.ajay.app/api/skipSegments/$prefix?" +
|
||||||
"categories=" + buildJsonArray(categories)
|
"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()
|
val req = Request.Builder()
|
||||||
.url(urlStr)
|
.url(urlStr)
|
||||||
.header("User-Agent", NewPipeDownloader.USER_AGENT)
|
.header("User-Agent", NewPipeDownloader.USER_AGENT)
|
||||||
|
|
@ -52,23 +53,28 @@ object SponsorBlockClient {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
NewPipeDownloader.client().newCall(req).execute().use { r ->
|
NewPipeDownloader.client().newCall(req).execute().use { r ->
|
||||||
val code = r.code
|
val code = r.code
|
||||||
val bodyStr = r.body?.string() ?: ""
|
// AUD-HIGH: bounded body read.
|
||||||
Log.d(TAG, "response: code=$code body_len=${bodyStr.length}")
|
val bodyStr = r.body?.cappedString(SB_MAX_BYTES) ?: ""
|
||||||
|
strawLogD(TAG) { "response: code=$code body_len=${bodyStr.length}" }
|
||||||
if (!r.isSuccessful) return@use emptyList()
|
if (!r.isSuccessful) return@use emptyList()
|
||||||
val all = runCatching {
|
val all = runCatching {
|
||||||
json.decodeFromString<List<SbVideoSegments>>(bodyStr)
|
json.decodeFromString<List<SbVideoSegments>>(bodyStr)
|
||||||
}.onFailure { Log.w(TAG, "json decode failed: ${it.message}") }
|
}.onFailure { strawLogW(TAG) { "json decode failed: ${it.message}" } }
|
||||||
.getOrDefault(emptyList())
|
.getOrDefault(emptyList())
|
||||||
val mine = all.firstOrNull { it.videoID == videoId }?.segments.orEmpty()
|
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
|
mine
|
||||||
}
|
}
|
||||||
}.onFailure { Log.w(TAG, "fetch failed: ${it.javaClass.simpleName}: ${it.message}") }
|
}.onFailure { strawLogW(TAG) { "fetch failed: ${it.javaClass.simpleName}: ${it.message}" } }
|
||||||
.getOrDefault(emptyList())
|
.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 =
|
private fun buildJsonArray(items: List<String>): String =
|
||||||
items.joinToString(",", prefix = "[", postfix = "]") { "\"$it\"" }
|
json.encodeToString(items)
|
||||||
|
|
||||||
private fun sha256Hex(s: String): String {
|
private fun sha256Hex(s: String): String {
|
||||||
val bytes = MessageDigest.getInstance("SHA-256").digest(s.toByteArray())
|
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
|
||||||
|
* 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
|
||||||
|
* 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