diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt
index 1b854d719..4ba83af96 100644
--- a/buildSrc/src/main/kotlin/ProjectConfig.kt
+++ b/buildSrc/src/main/kotlin/ProjectConfig.kt
@@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
// NewPipeExtractor in the runtime path.
-const val STRAW_VERSION_CODE = 34
-const val STRAW_VERSION_NAME = "0.1.0-AT"
+const val STRAW_VERSION_CODE = 35
+const val STRAW_VERSION_NAME = "0.1.0-AU"
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
diff --git a/strawApp/src/main/AndroidManifest.xml b/strawApp/src/main/AndroidManifest.xml
index d3d86d3e0..dff4e3cc1 100644
--- a/strawApp/src/main/AndroidManifest.xml
+++ b/strawApp/src/main/AndroidManifest.xml
@@ -11,12 +11,20 @@
+
+
+
-
+
@@ -39,6 +50,9 @@
+
+
+
diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt
index 86bc9f577..ead83af3f 100644
--- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt
@@ -207,7 +207,13 @@ class StrawActivity : ComponentActivity() {
// Explicit scheme + host check — defense in depth vs the
// manifest intent-filter; apps can synth intents that
// bypass filter scheme matching on exported activities.
- if (intent.scheme?.lowercase() !in setOf("https", "http")) return null
+ // HTTPS only — matches the manifest VIEW filter so an explicit
+ // ComponentName intent can't smuggle an http:// URL past the
+ // filter check. Defense in depth; the YT_URL_RE still allows
+ // http for the ACTION_SEND substring case where the URL is
+ // embedded in attacker-controlled text and we want to match
+ // common share-sheet links, but VIEW must be tighter.
+ if (intent.scheme?.lowercase() != "https") return null
if (!looksLikeYouTube(data)) return null
data
}
diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt
index df0e041e1..a6ae34c13 100644
--- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt
@@ -12,6 +12,7 @@ import com.sulkta.straw.data.Playlists
import com.sulkta.straw.data.SearchCache
import com.sulkta.straw.data.Settings
import com.sulkta.straw.data.Subscriptions
+import com.sulkta.straw.feature.dataimport.SettingsImport
class StrawApp : Application() {
override fun onCreate() {
@@ -27,5 +28,10 @@ class StrawApp : Application() {
Playlists.init(this)
FeedCache.init(this)
SearchCache.init(this)
+ // Sweep any newpipe-import-* work-dirs left in cacheDir by a
+ // previous import that was killed mid-extraction. CRIT from
+ // the vc=34 security audit — the user's full NewPipe DB would
+ // otherwise live in cacheDir until the next deleteRecursively.
+ SettingsImport.sweepStale(this)
}
}
diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt
index dd23aeae4..ff2d26d34 100644
--- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt
@@ -280,8 +280,12 @@ private fun SubsPane(
var visibleCount by remember { mutableIntStateOf(PAGE_SIZE) }
// O(1) lookup for the watched-filter; rebuild only when watches
- // change. Just the video IDs because URLs vary by tracking params.
- val watchedIds = remember(watches) { watches.map { it.videoId }.toSet() }
+ // change. Drop blank IDs — `recordWatch` doesn't gate on those,
+ // and a blank in the set would `extractVideoId(url)=""` match
+ // EVERY malformed-URL item and silently hide them all.
+ val watchedIds = remember(watches) {
+ watches.map { it.videoId }.filter { it.isNotBlank() }.toSet()
+ }
val filteredItems = remember(feed.items, hideWatched, watchedIds) {
if (!hideWatched) feed.items
@@ -388,11 +392,24 @@ private fun SubsPane(
lastVisible >= info.totalItemsCount - 5
}
}
- LaunchedEffect(displayed.size, hasMore) {
- snapshotFlow { nearBottom }.collect { atEnd ->
- if (atEnd && hasMore) {
+ // Key on listState only — the previous key set
+ // (displayed.size, hasMore) was mutated BY this effect,
+ // which cancelled the snapshotFlow collector mid-stream
+ // and produced the "scrolled to bottom, nothing loads"
+ // bug from the vc=34 audit.
+ //
+ // hasMore and filteredItems are read inside the
+ // snapshotFlow producer (not closed over from outside)
+ // so Compose re-reads them on each frame instead of
+ // capturing the stale value at lambda-creation time.
+ val filteredCount = filteredItems.size
+ LaunchedEffect(listState, filteredCount) {
+ snapshotFlow {
+ nearBottom && visibleCount < filteredCount
+ }.collect { shouldGrow ->
+ if (shouldGrow) {
visibleCount = (visibleCount + PAGE_SIZE)
- .coerceAtMost(filteredItems.size)
+ .coerceAtMost(filteredCount)
}
}
}
diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt
index 70d9a2c1b..0464d25a3 100644
--- a/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt
@@ -36,7 +36,7 @@ private const val KEY = "cache_v1"
class FeedCacheStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
- private val json = Json { ignoreUnknownKeys = true; isLenient = true }
+ private val json = Json { ignoreUnknownKeys = true }
/** Snapshot of the disk cache. Returns empty map if nothing saved. */
fun load(): Map = runCatching {
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 cc04a788c..7ecc397d7 100644
--- a/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt
@@ -36,7 +36,7 @@ private const val MAX_SEARCHES = 20
class HistoryStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
- private val json = Json { ignoreUnknownKeys = true; isLenient = true }
+ private val json = Json { ignoreUnknownKeys = true }
private val _watches = MutableStateFlow(loadWatches())
val watches: StateFlow> = _watches.asStateFlow()
@@ -56,6 +56,30 @@ class HistoryStore(context: Context) {
sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply()
}
+ /**
+ * Bulk import. Callers (currently SettingsImport) feed
+ * oldest→newest so the most-recent entries end up at the front
+ * of the capped list. Single SP write — vc=34 audit flagged the
+ * per-row recordWatch in importHistory as a write-storm vector.
+ */
+ fun recordAllWatches(items: List) {
+ if (items.isEmpty()) return
+ val next = _watches.updateAndGet { current ->
+ val seen = current.map { it.videoId }.toMutableSet()
+ val merged = current.toMutableList()
+ for (item in items) {
+ if (item.videoId.isBlank()) continue
+ if (item.videoId in seen) {
+ merged.removeAll { it.videoId == item.videoId }
+ }
+ seen.add(item.videoId)
+ merged.add(0, item)
+ }
+ merged.take(MAX_WATCHES)
+ }
+ sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply()
+ }
+
fun recordSearch(query: String) {
val q = query.trim()
if (q.isEmpty()) return
diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt
index cf72bd597..57f8979f6 100644
--- a/strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt
@@ -47,7 +47,7 @@ private const val KEY = "playlists_v1"
class PlaylistsStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
- private val json = Json { ignoreUnknownKeys = true; isLenient = true }
+ private val json = Json { ignoreUnknownKeys = true }
private val _playlists = MutableStateFlow(load())
val playlists: StateFlow> = _playlists.asStateFlow()
diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt
index 7dc05cc62..10f5a961d 100644
--- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt
@@ -37,7 +37,7 @@ private const val MAX_ITEMS_PER_QUERY = 20
class SearchCacheStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
- private val json = Json { ignoreUnknownKeys = true; isLenient = true }
+ private val json = Json { ignoreUnknownKeys = true }
fun load(): List = runCatching {
val s = sp.getString(KEY, null) ?: return emptyList()
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 303ac4678..e4d897927 100644
--- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt
@@ -29,7 +29,7 @@ private const val KEY = "subs_v1"
class SubscriptionsStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
- private val json = Json { ignoreUnknownKeys = true; isLenient = true }
+ private val json = Json { ignoreUnknownKeys = true }
private val _subs = MutableStateFlow(load())
val subs: StateFlow> = _subs.asStateFlow()
@@ -64,6 +64,31 @@ class SubscriptionsStore(context: Context) {
persist(next)
}
+ /**
+ * Bulk-add. Single persist instead of N. Per-call `toggle()` was
+ * O(N²) + N SP writes, which the vc=34 security audit flagged as
+ * a DoS vector for hostile NewPipe-export imports. Single linear
+ * scan to dedup, one persist regardless of input size. Returns the
+ * count of NEW (not previously-subscribed) channels added so the
+ * caller can report an "added X" stat.
+ */
+ fun addAll(refs: List): Int {
+ var added = 0
+ val next = _subs.updateAndGet { cur ->
+ val byUrl = cur.associateBy { it.url }.toMutableMap()
+ for (r in refs) {
+ if (r.url.isBlank()) continue
+ if (r.url !in byUrl) {
+ byUrl[r.url] = r
+ added++
+ }
+ }
+ byUrl.values.toList()
+ }
+ persist(next)
+ return added
+ }
+
fun clear() {
// Same atomic-update path as toggle — protects against a concurrent
// toggle racing the clear and persisting [new-item] after the
diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt
index 4e138788e..f2bab6d72 100644
--- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt
@@ -38,6 +38,7 @@ import com.sulkta.straw.data.WatchHistoryItem
import java.io.File
import java.util.zip.ZipInputStream
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
@@ -106,10 +107,29 @@ object SettingsImport {
runCatching { runInner(context, zipUri) }
}
- private fun runInner(context: Context, zipUri: Uri): ImportResult {
+ /**
+ * Sweep stale import work-dirs left behind by a previous run that
+ * was killed mid-extraction. CRIT from the vc=34 security audit:
+ * a force-killed import leaves the user's full newpipe.db sitting
+ * in cacheDir indefinitely. StrawApp.onCreate calls this on every
+ * cold start.
+ */
+ fun sweepStale(context: Context) {
+ runCatching {
+ context.cacheDir.listFiles { f ->
+ f.isDirectory && f.name.startsWith("newpipe-import-")
+ }?.forEach { it.deleteRecursively() }
+ }
+ }
+
+ private suspend fun runInner(context: Context, zipUri: Uri): ImportResult {
val warnings = mutableListOf()
- val workDir = File(context.cacheDir, "newpipe-import-${System.currentTimeMillis()}")
- workDir.mkdirs()
+ // createTempFile returns an unguessable name and 0600 perms by
+ // default, replacing the predictable currentTimeMillis suffix
+ // that an attacker could pre-create a symlink at.
+ val workDir = File.createTempFile("newpipe-import-", "", context.cacheDir).also {
+ it.delete(); it.mkdirs()
+ }
try {
val (dbFile, prefsJson) = extractZip(context, zipUri, workDir, warnings)
@@ -132,10 +152,22 @@ object SettingsImport {
warnings = warnings,
)
} finally {
- workDir.deleteRecursively()
+ // NonCancellable guarantees the cleanup runs even when the
+ // outer coroutine was cancelled — without it a user
+ // navigating away mid-import (or low-memory killer firing)
+ // left the full newpipe.db in cacheDir until the next
+ // cold-start sweep.
+ withContext(NonCancellable) {
+ workDir.deleteRecursively()
+ }
}
}
+ // Defense against zip-bomb / malformed exports.
+ private const val MAX_DB_BYTES: Long = 256L * 1024 * 1024
+ private const val MAX_PREFS_BYTES: Long = 1L * 1024 * 1024
+ private const val MAX_ZIP_ENTRIES: Int = 64
+
private fun extractZip(
context: Context,
zipUri: Uri,
@@ -144,24 +176,37 @@ object SettingsImport {
): Pair {
var dbFile: File? = null
var prefs: JsonObject? = null
+ var entryCount = 0
context.contentResolver.openInputStream(zipUri)?.use { input ->
ZipInputStream(input).use { zip ->
while (true) {
val entry = zip.nextEntry ?: break
+ entryCount++
+ if (entryCount > MAX_ZIP_ENTRIES) {
+ warnings += "archive has >$MAX_ZIP_ENTRIES entries — aborting"
+ return null to null
+ }
when (entry.name) {
"newpipe.db" -> {
val out = File(workDir, "newpipe.db")
- out.outputStream().use { os ->
- zip.copyTo(os, bufferSize = 64 * 1024)
+ val written = copyBounded(zip, out, MAX_DB_BYTES)
+ if (written < 0L) {
+ warnings += "newpipe.db exceeds ${MAX_DB_BYTES / (1024 * 1024)} MB — aborting"
+ out.delete()
+ return null to null
}
dbFile = out
}
"preferences.json" -> {
- val bytes = zip.readBytes()
- prefs = runCatching {
- Json.parseToJsonElement(bytes.decodeToString()) as? JsonObject
- }.getOrNull()
- if (prefs == null) warnings += "preferences.json present but unparseable"
+ val bytes = readBoundedBytes(zip, MAX_PREFS_BYTES)
+ if (bytes == null) {
+ warnings += "preferences.json exceeds ${MAX_PREFS_BYTES / 1024} KB — skipping"
+ } else {
+ prefs = runCatching {
+ Json.parseToJsonElement(bytes.decodeToString()) as? JsonObject
+ }.getOrNull()
+ if (prefs == null) warnings += "preferences.json present but unparseable"
+ }
}
// newpipe.settings is the legacy XML form; preferences.json
// supersedes it in every modern export. Skip.
@@ -176,14 +221,51 @@ object SettingsImport {
return dbFile to prefs
}
+ /**
+ * Bounded copy. Returns bytes-written on success, -1 if `cap` was
+ * exceeded. Used instead of `copyTo` so a 16 GB zip-bomb doesn't
+ * fill the user's cacheDir before we notice.
+ */
+ private fun copyBounded(src: java.io.InputStream, dst: File, cap: Long): Long {
+ dst.outputStream().use { os ->
+ val buf = ByteArray(64 * 1024)
+ var total = 0L
+ while (true) {
+ val n = src.read(buf)
+ if (n <= 0) break
+ total += n
+ if (total > cap) return -1L
+ os.write(buf, 0, n)
+ }
+ return total
+ }
+ }
+
+ private fun readBoundedBytes(src: java.io.InputStream, cap: Long): ByteArray? {
+ val baos = java.io.ByteArrayOutputStream()
+ val buf = ByteArray(16 * 1024)
+ var total = 0L
+ while (true) {
+ val n = src.read(buf)
+ if (n <= 0) break
+ total += n
+ if (total > cap) return null
+ baos.write(buf, 0, n)
+ }
+ return baos.toByteArray()
+ }
+
private data class SubsResult(val added: Int, val skipped: Int)
private fun importSubscriptions(dbFile: File): SubsResult {
val store = Subscriptions.get()
- var added = 0
+ // Cap input row count too — hostile NewPipe export with a
+ // million rows would still walk the cursor fully without this.
+ val maxRows = 10_000
var skipped = 0
+ val staged = mutableListOf()
openDb(dbFile).use { db ->
db.rawQuery(
- "SELECT url, name, avatar_url, service_id FROM subscriptions",
+ "SELECT url, name, avatar_url, service_id FROM subscriptions LIMIT $maxRows",
null,
).use { c ->
while (c.moveToNext()) {
@@ -195,13 +277,12 @@ object SettingsImport {
val url = c.getString(0) ?: continue
val name = c.getString(1) ?: continue
val avatar = c.getString(2)
- if (!store.isSubscribed(url)) {
- store.toggle(ChannelRef(url = url, name = name, avatar = avatar))
- added++
- }
+ staged += ChannelRef(url = url, name = name, avatar = avatar)
}
}
}
+ // Single dedup + single persist regardless of N.
+ val added = store.addAll(staged)
return SubsResult(added, skipped)
}
@@ -293,12 +374,17 @@ object SettingsImport {
db.rawQuery("SELECT COUNT(*) FROM stream_history", null).use { c ->
if (c.moveToNext()) watchesAvailable = c.getInt(0)
}
+ // Stage rows in memory, then one bulk write — same DoS
+ // mitigation as importSubscriptions. recordWatch did N SP
+ // writes and an O(N) dedup per row.
+ val staged = mutableListOf()
db.rawQuery(
"""
SELECT s.url, s.title, s.uploader, s.thumbnail_url, h.access_date, s.service_id
FROM stream_history h
JOIN streams s ON s.uid = h.stream_id
ORDER BY h.access_date ASC
+ LIMIT 50000
""".trimIndent(),
null,
).use { c ->
@@ -309,19 +395,18 @@ object SettingsImport {
val uploader = c.getString(2) ?: ""
val thumb = c.getString(3)
val videoId = extractYtVideoId(url) ?: continue
- historyStore.recordWatch(
- WatchHistoryItem(
- url = url,
- videoId = videoId,
- title = title,
- uploader = uploader,
- thumbnail = thumb,
- watchedAt = c.getLong(4),
- ),
+ staged += WatchHistoryItem(
+ url = url,
+ videoId = videoId,
+ title = title,
+ uploader = uploader,
+ thumbnail = thumb,
+ watchedAt = c.getLong(4),
)
watchesSeen++
}
}
+ historyStore.recordAllWatches(staged)
// Resume positions — counted, not stored. Future task hooks into
// a ResumePositionsStore.
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 59f48f148..8e6304966 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
@@ -65,6 +65,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -155,7 +156,9 @@ fun VideoDetailScreen(
val dismissThresholdPx = with(density) { 140.dp.toPx() }
val flingVelocityThreshold = with(density) { 600.dp.toPx() }
val screenHeightPx = with(density) { configuration.screenHeightDp.dp.toPx() }
- var liveDrag by remember { mutableStateOf(0f) }
+ // mutableFloatStateOf avoids boxing on every drag delta — the
+ // draggable callback fires 100+ times/s on a fast swipe.
+ var liveDrag by remember { mutableFloatStateOf(0f) }
var dragging by remember { mutableStateOf(false) }
val releaseAnim = remember { Animatable(0f) }
val draggableState = rememberDraggableState { delta ->
@@ -385,21 +388,23 @@ fun VideoDetailScreen(
Toast.makeText(context, "PiP needs Android 8+", Toast.LENGTH_SHORT).show()
return@OutlinedButton
}
- // PiP needs the controller to actually be playing
- // this video, same as Background — otherwise we
- // pop out into nothing.
+ // PiP into nothing isn't useful — bail with a
+ // Toast if there's no controller / no resolved
+ // playback to push into it. vc=34 audit Q-13.
val c = controller
- if (c != null && NowPlaying.current.value?.streamUrl != streamUrl) {
- val r = state.resolved
- if (r != null) {
- c.setPlayingFrom(
- streamUrl = streamUrl,
- title = d.title,
- uploader = d.uploader,
- thumbnail = d.thumbnail,
- resolved = r,
- )
- }
+ val r = state.resolved
+ if (c == null || r == null) {
+ Toast.makeText(context, "stream not ready", Toast.LENGTH_SHORT).show()
+ return@OutlinedButton
+ }
+ if (NowPlaying.current.value?.streamUrl != streamUrl) {
+ c.setPlayingFrom(
+ streamUrl = streamUrl,
+ title = d.title,
+ uploader = d.uploader,
+ thumbnail = d.thumbnail,
+ resolved = r,
+ )
}
val params = PictureInPictureParams.Builder()
.setAspectRatio(Rational(16, 9))
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 e269b3f6c..19e2b2fb5 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
@@ -195,13 +195,22 @@ class VideoDetailViewModel : ViewModel() {
segments: List,
): ResolvedPlayback {
val maxRes = Settings.get().maxResolution.value.ceiling
- // Filter by max-resolution ceiling but fall back to the lowest
- // available if the ceiling excludes everything (e.g. a 360p-only
- // upload with the user on a 480p cap).
+ // Pick the highest-bitrate stream that still fits the user's
+ // cap. Fallback: when every available stream EXCEEDS the cap
+ // (e.g. a 1080p-only upload with the user on a 480p cap), pick
+ // the LOWEST-height one — that's the closest-to-cap option and
+ // honors the user's intent ("don't blow my data plan") even
+ // when their exact target isn't available. vc=34 audit Q-8 —
+ // previously this fell back to max-bitrate, which was the
+ // worst possible choice for someone on a 480p cap.
fun pickVideo(streams: List): String? {
if (streams.isEmpty()) return null
- val pool = streams.filter { it.height <= maxRes }.ifEmpty { streams }
- return pool.maxByOrNull { it.bitrate }?.url
+ val capped = streams.filter { it.height <= maxRes }
+ return if (capped.isNotEmpty()) {
+ capped.maxByOrNull { it.bitrate }?.url
+ } else {
+ streams.minByOrNull { it.height }?.url
+ }
}
return ResolvedPlayback(
title = info.title,
diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/Downloader.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/Downloader.kt
index bdb7dcce3..6ce34060e 100644
--- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/Downloader.kt
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/Downloader.kt
@@ -51,11 +51,24 @@ object Downloader {
val filename = "$safeTitle${kind.ext}"
val dm = ctx.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
+ // SECURITY: pre-signed googlevideo URLs leak to anything reading
+ // DownloadManager state (system notification stack, downloads UI,
+ // apps with ACCESS_DOWNLOAD_MANAGER). We can't hide the URL from
+ // DM itself without re-implementing the download, but we can hide
+ // it from every surface DM forwards to:
+ // setNotificationVisibility(HIDDEN) — no system notification
+ // surfaces the URL via tap-to-open / accessibility scrapers.
+ // setVisibleInDownloadsUi(false) — the Downloads system app
+ // won't list this entry, so a user opening Files / Downloads
+ // can't long-press → details → see the URL.
+ // Our own DownloadsScreen reads progress out of DM via the ID
+ // returned below, so user-facing UX is unaffected.
val req = runCatching {
DownloadManager.Request(Uri.parse(url))
.setTitle(title)
.setDescription("Straw — ${kind.name.lowercase()}")
- .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
+ .setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN)
+ .setVisibleInDownloadsUi(false)
.setAllowedOverMetered(true)
.setAllowedOverRoaming(true)
.setDestinationInExternalFilesDir(
diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/DownloadsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/DownloadsScreen.kt
index 5872a5c17..30ec8cc94 100644
--- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/DownloadsScreen.kt
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/DownloadsScreen.kt
@@ -156,8 +156,27 @@ private fun DownloadRowView(
.fillMaxWidth()
.clickable(enabled = openable) {
row.localUri?.let { uri ->
+ // DownloadManager returns a file:// URI for the
+ // setDestinationInExternalFilesDir target. Passing
+ // that across an app boundary throws
+ // FileUriExposedException on every API >= 24 since
+ // minSdk 24. Route through FileProvider so the
+ // receiver gets a grantable content:// URI instead.
+ val shareUri = runCatching {
+ val src = Uri.parse(uri)
+ val path = src.path
+ if (src.scheme == "file" && path != null) {
+ androidx.core.content.FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.fileprovider",
+ java.io.File(path),
+ )
+ } else {
+ src
+ }
+ }.getOrNull() ?: return@let
val intent = Intent(Intent.ACTION_VIEW).apply {
- setDataAndType(Uri.parse(uri), row.mediaType ?: "*/*")
+ setDataAndType(shareUri, row.mediaType ?: "*/*")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
runCatching { context.startActivity(intent) }
diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt
index 2503a828c..fbdff939b 100644
--- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt
@@ -66,14 +66,14 @@ class SubscriptionFeedViewModel : ViewModel() {
init {
// Hydrate from disk and immediately render the cached items so
- // the Subs tab paints in one frame instead of after the network
- // round-trip. The refresh that follows replaces stale entries
- // in-place — items animate to their new positions via LazyColumn
- // key stability (URLs are stable across fetches).
- // Skip the hydrate when the user has disabled caching — they
- // explicitly don't want disk usage for this.
- val saved = if (Settings.get().cacheEnabled.value) FeedCache.get().load() else emptyMap()
- if (saved.isNotEmpty()) {
+ // the Subs tab paints before the network round-trip resolves.
+ // vc=34 audit CRIT: previously this ran synchronously on the
+ // main thread at VM construction, blocking the first compose
+ // pass on a ~225 KB Json.decodeFromString.
+ viewModelScope.launch {
+ if (!Settings.get().cacheEnabled.value) return@launch
+ val saved = withContext(Dispatchers.IO) { FeedCache.get().load() }
+ if (saved.isEmpty()) return@launch
channelCache.putAll(saved)
val channels = Subscriptions.get().subs.value
if (channels.isNotEmpty()) {
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 0c3ee8f01..47eccf046 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
@@ -52,6 +52,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -89,7 +90,7 @@ fun PlayerScreen(
val state by vm.ui.collectAsStateWithLifecycle()
LaunchedEffect(streamUrl) { vm.load(streamUrl) }
- var playbackSpeed by remember { mutableStateOf(1.0f) }
+ var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
var audioOnly by remember { mutableStateOf(false) }
var showSpeedDialog by remember { mutableStateOf(false) }
@@ -301,7 +302,7 @@ private fun SpeedPickerDialog(
options.forEach { s ->
Row(
modifier = Modifier
- .fillMaxSize()
+ .fillMaxWidth()
.clickable { onPick(s) }
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -341,6 +342,10 @@ fun SponsorBlockSkipLoop() {
val segments = cur.segments
if (segments.isEmpty() || controller == null) return
val skipped = remember(cur.streamUrl) { mutableSetOf() }
+ // Rate-limit the skip Toast — back-to-back segments in
+ // sponsor-dense videos used to queue 20+ Toasts that paint over
+ // the screen for 40s after the actual seek (vc=34 audit HIGH-B7).
+ var lastToastAt by remember(cur.streamUrl) { mutableStateOf(0L) }
LaunchedEffect(cur.streamUrl, controller) {
while (true) {
delay(150)
@@ -360,7 +365,11 @@ fun SponsorBlockSkipLoop() {
controller.seekTo(targetMs)
}
s.UUID?.let { skipped.add(it) }
- Toast.makeText(context, "skipped ${s.category}", Toast.LENGTH_SHORT).show()
+ val now = System.currentTimeMillis()
+ if (now - lastToastAt > 3000) {
+ Toast.makeText(context, "skipped ${s.category}", Toast.LENGTH_SHORT).show()
+ lastToastAt = now
+ }
}
}
}
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 fce5263c0..c0c7a3434 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
@@ -69,12 +69,19 @@ fun SearchScreen(
Spacer(modifier = Modifier.height(12.dp))
when {
- state.loading -> Box(
+ // Loading WITH cached results: thin progress bar above the
+ // list, results stay visible. vc=34 audit B-1 — the prior
+ // order short-circuited to a centered spinner and hid the
+ // cached preview the VM was trying to show.
+ state.loading && state.results.isEmpty() -> Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
- state.error != null -> Box(
+ // Error WITH cached results: thin error banner above the
+ // list. Audit B-2 — error branch used to clobber the
+ // cached preview the VM explicitly kept.
+ state.error != null && state.results.isEmpty() -> Box(
modifier = Modifier.fillMaxSize().padding(16.dp),
contentAlignment = Alignment.Center,
) {
@@ -117,6 +124,20 @@ fun SearchScreen(
) { Text("hit enter to search") }
else -> Column(modifier = Modifier.fillMaxSize()) {
+ if (state.loading) {
+ androidx.compose.material3.LinearProgressIndicator(
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ }
+ if (state.error != null) {
+ Text(
+ text = "refresh failed: ${state.error}",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.error,
+ modifier = Modifier.padding(bottom = 4.dp),
+ )
+ }
if (state.fromCache) {
Text(
text = if (state.loading) "Cached results · refreshing…"
diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt
index a816c18b2..6cb952a1f 100644
--- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt
@@ -49,12 +49,34 @@ class SearchViewModel : ViewModel() {
private val _ui = MutableStateFlow(SearchUiState())
val ui: StateFlow = _ui.asStateFlow()
+ /**
+ * In-memory snapshot of the disk corpus (saved search results +
+ * subs feed cache) for reactive filtering. Hydrated on Dispatchers.IO
+ * once at VM construction and refreshed after a successful submit.
+ * vc=34 audit CRIT — the previous implementation hit
+ * SharedPreferences + JSON-decoded ~225 KB on every keystroke,
+ * blocking the main thread.
+ */
+ private val pool = MutableStateFlow>(emptyList())
+
+ init {
+ viewModelScope.launch {
+ if (Settings.get().cacheEnabled.value) {
+ pool.value = withContext(Dispatchers.IO) { buildPool() }
+ }
+ }
+ }
+
+ private fun buildPool(): List = buildList {
+ runCatching { SearchCache.get().load().forEach { addAll(it.items) } }
+ runCatching { FeedCache.get().load().values.forEach { addAll(it.items) } }
+ }.distinctBy { it.url }
+
fun onQueryChange(q: String) {
_ui.value = _ui.value.copy(query = q)
- // Reactive filter: scan every cached item (search-cache + subs
- // feed-cache) as the user types. Cheap, runs in-memory, gives
- // instant feedback before they hit Enter. Disabled when the
- // user has turned off the cache feature.
+ // Reactive filter: scan the in-memory `pool` as the user types.
+ // Pool is a List walked once per keystroke — bounded
+ // (~1500 items typical), no disk I/O, no JSON decode.
if (Settings.get().cacheEnabled.value && q.trim().length >= 2) {
val matches = reactiveFilter(q.trim())
if (matches.isNotEmpty()) {
@@ -64,6 +86,11 @@ class SearchViewModel : ViewModel() {
loading = false,
error = null,
)
+ } else if (_ui.value.fromCache) {
+ // User typed past what the cache can answer — drop the
+ // stale preview rather than leaving the prior query's
+ // results on screen pretending to match.
+ _ui.value = _ui.value.copy(results = emptyList(), fromCache = false)
}
} else if (q.isBlank()) {
// Clear cached preview if the box is cleared.
@@ -126,6 +153,10 @@ class SearchViewModel : ViewModel() {
if (Settings.get().cacheEnabled.value) {
withContext(Dispatchers.IO) {
runCatching { SearchCache.get().record(q, items) }
+ // Refresh the in-memory pool with the new
+ // entries so subsequent reactive filters see
+ // them without waiting for a process restart.
+ pool.value = buildPool()
}
}
} catch (t: Throwable) {
@@ -140,24 +171,18 @@ class SearchViewModel : ViewModel() {
}
/**
- * Walk the merged corpus of cached items (every saved search +
- * every subs-feed channel cache) and return items whose title or
- * uploader contains the query — case-insensitive, dedup by URL.
- * Cheap: even with 30 cached queries * 20 items + 30 channels * 30
- * items it's < 1500 records, plenty fast for an in-memory filter.
+ * Walk the in-memory `pool` and return items whose title or uploader
+ * contains the query. Case-insensitive, capped at 60 results.
+ * No disk I/O on the hot path — `pool` is refreshed off-thread
+ * after each successful submit and at VM construction.
*/
private fun reactiveFilter(q: String): List {
val needle = q.lowercase()
- val pool = buildList {
- SearchCache.get().load().forEach { addAll(it.items) }
- FeedCache.get().load().values.forEach { addAll(it.items) }
- }
- return pool.asSequence()
+ return pool.value.asSequence()
.filter { item ->
item.title.lowercase().contains(needle)
|| item.uploader.lowercase().contains(needle)
}
- .distinctBy { it.url }
.take(60)
.toList()
}
diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt
index 1f8339ca8..c5e834837 100644
--- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt
@@ -244,20 +244,30 @@ fun SettingsScreen() {
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(12.dp))
- OutlinedButton(onClick = {
- val outcome = LogDump.capture(context)
- outcome.onSuccess { intent ->
- context.startActivity(android.content.Intent.createChooser(intent, "Share Straw logs"))
- }
- outcome.onFailure { t ->
- Toast.makeText(
- context,
- "Log dump failed: ${t.message ?: t.javaClass.simpleName}",
- Toast.LENGTH_LONG,
- ).show()
- }
- }) {
- Text("Export logs…")
+ var logDumping by remember { mutableStateOf(false) }
+ OutlinedButton(
+ enabled = !logDumping,
+ onClick = {
+ logDumping = true
+ scope.launch {
+ val outcome = LogDump.capture(context)
+ logDumping = false
+ outcome.onSuccess { intent ->
+ context.startActivity(
+ android.content.Intent.createChooser(intent, "Share Straw logs"),
+ )
+ }
+ outcome.onFailure { t ->
+ Toast.makeText(
+ context,
+ "Log dump failed: ${t.message ?: t.javaClass.simpleName}",
+ Toast.LENGTH_LONG,
+ ).show()
+ }
+ }
+ },
+ ) {
+ Text(if (logDumping) "Exporting…" else "Export logs…")
}
Spacer(modifier = Modifier.height(32.dp))
diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/net/IosSafeHttpDataSource.kt b/strawApp/src/main/kotlin/com/sulkta/straw/net/IosSafeHttpDataSource.kt
index ffb92672f..d0e1108ba 100644
--- a/strawApp/src/main/kotlin/com/sulkta/straw/net/IosSafeHttpDataSource.kt
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/net/IosSafeHttpDataSource.kt
@@ -21,11 +21,12 @@
package com.sulkta.straw.net
-import android.util.Log
import androidx.media3.common.C
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.HttpDataSource
+import com.sulkta.straw.util.strawLogD
+import com.sulkta.straw.util.strawLogW
private const val TAG = "IosSafeDS"
@@ -60,17 +61,19 @@ class IosSafeHttpDataSource(
// come out as `bytes=N-M` (closed, accepted by googlevideo iOS URLs)
// instead of `bytes=N-` (open, rejected with 403).
val bounded = dataSpec.buildUpon().setLength(requestLen).build()
- // Surface itag + mime from query so we can tell video vs audio apart in logs.
+ // Surface itag + mime from query so we can tell video vs audio apart in
+ // logs. SECURITY: do NOT include the full URL — pre-signed googlevideo
+ // URLs contain session-bound credentials (signature, sig, pot, expire,
+ // cpn) that would otherwise ride a `LogDump.capture` straight into the
+ // user's share-sheet target. Host + itag is enough to debug from.
val u = dataSpec.uri
val itag = u.getQueryParameter("itag")
val mime = u.getQueryParameter("mime")
- Log.i(
- TAG,
- "open: pos=${bounded.position} len=${bounded.length} " +
- "(origLen=${dataSpec.length}, chunkBytes=$chunkBytes) " +
- "itag=$itag mime=$mime host=${u.host}",
- )
- Log.i(TAG, "open url=${u.toString()}")
+ strawLogD(TAG) {
+ "open: host=${u.host} itag=$itag mime=$mime " +
+ "pos=${bounded.position} len=${bounded.length} " +
+ "(origLen=${dataSpec.length}, chunkBytes=$chunkBytes)"
+ }
originalSpec = dataSpec
totalRead = 0
// inner.open() returns the BOUNDED chunk's length. Track it so we
@@ -78,10 +81,10 @@ class IosSafeHttpDataSource(
chunkRemaining = try {
inner.open(bounded)
} catch (t: Throwable) {
- Log.w(TAG, "open failed: ${t.javaClass.simpleName}: ${t.message}")
+ strawLogW(TAG, t) { "open failed: ${t.javaClass.simpleName}" }
throw t
}
- Log.i(TAG, "open: inner returned chunkRemaining=$chunkRemaining")
+ strawLogD(TAG) { "open: inner chunkRemaining=$chunkRemaining" }
// Report the original (potentially unbounded) length to the caller —
// ExoPlayer cares about the overall length, not our internal chunking.
return if (dataSpec.length == C.LENGTH_UNSET.toLong()) {
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 817597596..6a5bb17d8 100644
--- a/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt
@@ -25,7 +25,7 @@ data class RydVotes(
object RydClient {
private const val TAG = "StrawRyd"
- private val json = Json { ignoreUnknownKeys = true; isLenient = true }
+ private val json = Json { ignoreUnknownKeys = true }
/** Blocking — call from Dispatchers.IO. */
fun fetch(videoId: String): RydVotes? {
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 9979e58f8..77688c5ed 100644
--- a/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt
@@ -34,7 +34,7 @@ data class SbSegment(
object SponsorBlockClient {
private const val TAG = "StrawSb"
- private val json = Json { ignoreUnknownKeys = true; isLenient = true }
+ private val json = Json { ignoreUnknownKeys = true }
fun fetch(
videoId: String,
diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt b/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt
index 9b4675aeb..d66eac8a6 100644
--- a/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt
@@ -6,10 +6,14 @@
* Used from the Settings → "Export logs" action so users can attach a
* log dump when reporting a problem.
*
- * NOTE: Android limits logcat-via-Runtime.exec to the calling app's
- * own UID on API 30+, so this captures Straw's own log lines only
- * (plus a sliver of system-wide messages tagged by our PID). No
- * other app's logs are exposed.
+ * SECURITY: The dump is filtered before being written to disk —
+ * pre-signed googlevideo URLs, OAuth-style tokens, and anything
+ * matching the leak patterns below get scrubbed line-by-line. Without
+ * this, a user reporting a bug to Telegram would hand the chooser app
+ * their currently-playing session-bound streaming credentials.
+ *
+ * Android also limits logcat-via-Runtime.exec to the calling app's
+ * own UID on API 30+, so this captures Straw's own log lines only.
*/
package com.sulkta.straw.util
@@ -24,53 +28,89 @@ import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
object LogDump {
/**
- * Pull recent logcat, write to a file in cacheDir, return a
- * share-able Intent. Caller is responsible for `startActivity`.
- * Returns null when the dump command itself fails — the caller
- * shows a Toast with the error.
+ * Pull recent logcat, scrub sensitive substrings, write to a file
+ * in cacheDir, return a share-able Intent. Suspend so callers can
+ * stay off the main thread — `proc.waitFor()` plus a multi-MB
+ * `copyTo` is firmly an IO operation.
*/
- fun capture(context: Context): Result = runCatching {
- val pid = Process.myPid()
- val timestamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
- val outFile = File(context.cacheDir, "straw-logs-$timestamp.txt")
+ suspend fun capture(context: Context): Result = withContext(Dispatchers.IO) {
+ runCatching {
+ val pid = Process.myPid()
+ val timestamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
+ val outFile = File(context.cacheDir, "straw-logs-$timestamp.txt")
+ val tmpFile = File(context.cacheDir, "straw-logs-$timestamp.txt.tmp")
- // -d dump-and-exit (no follow), -v threadtime is the
- // most-greppable format, and the --pid filter restricts to
- // our process so we don't accidentally exfiltrate sibling
- // apps' chatter even when the system would allow it.
- val cmd = arrayOf(
- "logcat",
- "-d",
- "-v",
- "threadtime",
- "--pid=$pid",
- )
- val proc = ProcessBuilder(*cmd)
- .redirectErrorStream(true)
- .start()
- outFile.outputStream().use { out ->
- proc.inputStream.copyTo(out)
- }
- val exit = proc.waitFor()
- if (exit != 0) {
- throw IOException("logcat exit=$exit")
- }
- if (outFile.length() == 0L) {
- throw IOException("logcat produced 0 bytes (sandbox restriction?)")
- }
+ // Sweep old dumps before writing the new one so cacheDir
+ // doesn't grow per export.
+ context.cacheDir.listFiles { _, name ->
+ name.startsWith("straw-logs-") && (name.endsWith(".txt") || name.endsWith(".tmp"))
+ }?.forEach { it.delete() }
- // FileProvider authority — declared in AndroidManifest below.
- val authority = "${context.packageName}.fileprovider"
- val uri: Uri = FileProvider.getUriForFile(context, authority, outFile)
- Intent(Intent.ACTION_SEND).apply {
- type = "text/plain"
- putExtra(Intent.EXTRA_STREAM, uri)
- putExtra(Intent.EXTRA_SUBJECT, "Straw logs $timestamp")
- addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ // -d dump-and-exit (no follow), -v threadtime is the
+ // most-greppable format, --pid filter restricts to our
+ // process so we don't exfiltrate sibling apps' chatter.
+ val cmd = arrayOf("logcat", "-d", "-v", "threadtime", "--pid=$pid")
+ val proc = ProcessBuilder(*cmd).redirectErrorStream(true).start()
+ tmpFile.bufferedWriter().use { out ->
+ proc.inputStream.bufferedReader().useLines { lines ->
+ lines.forEach { line ->
+ out.write(scrubLine(line))
+ out.newLine()
+ }
+ }
+ }
+ val exit = proc.waitFor()
+ if (exit != 0) {
+ tmpFile.delete()
+ throw IOException("logcat exit=$exit")
+ }
+ if (tmpFile.length() == 0L) {
+ tmpFile.delete()
+ throw IOException("logcat produced 0 bytes (sandbox restriction?)")
+ }
+ // Atomic-ish: only rename to final name on full success.
+ if (!tmpFile.renameTo(outFile)) {
+ tmpFile.delete()
+ throw IOException("rename failed")
+ }
+
+ val authority = "${context.packageName}.fileprovider"
+ val uri: Uri = FileProvider.getUriForFile(context, authority, outFile)
+ Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_STREAM, uri)
+ putExtra(Intent.EXTRA_SUBJECT, "Straw logs $timestamp")
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
}
}
+
+ /**
+ * Pre-redact known credential-shaped substrings before they hit
+ * disk. Cheap line-level pass — adversarial-perfect would need a
+ * URL parser, but the regex approach catches every documented
+ * leak vector at zero allocation cost.
+ */
+ internal fun scrubLine(line: String): String {
+ var s = line
+ // Pre-signed googlevideo URLs: keep host visible, drop path+query.
+ s = GOOGLEVIDEO_URL_RE.replace(s, "https://.googlevideo.com/")
+ // Any remaining signed-param shapes that snuck through other URLs.
+ s = SIGNED_PARAM_RE.replace(s, "$1=")
+ return s
+ }
+
+ private val GOOGLEVIDEO_URL_RE = Regex(
+ """https?://[a-zA-Z0-9.-]*googlevideo\.com/\S+""",
+ )
+ private val SIGNED_PARAM_RE = Regex(
+ """\b(signature|sig|pot|cpn|expire|ip|mn|ms|mo|pl)=([^&\s"']+)""",
+ RegexOption.IGNORE_CASE,
+ )
}
diff --git a/strawApp/src/main/res/xml/data_extraction_rules.xml b/strawApp/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 000000000..163056f5c
--- /dev/null
+++ b/strawApp/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/strawApp/src/main/res/xml/file_paths.xml b/strawApp/src/main/res/xml/file_paths.xml
index 92e012475..2cd9f5c6f 100644
--- a/strawApp/src/main/res/xml/file_paths.xml
+++ b/strawApp/src/main/res/xml/file_paths.xml
@@ -1,8 +1,14 @@
-
+
+
+
+
+