vc=35: audit-fix sprint — 5 CRIT + 14 HIGH + opportunistic MEDs

Three Opus max-effort audits (CVE/security, code-health, function-
correctness) on the vc=34 surface returned a consolidated punch list
of 5 CRIT + 14 HIGH + ~10 MED. This commit lands all CRIT + HIGH +
the cheap MEDs in one cohesive pass.

CRIT — privacy + main-thread blocks
  S1  IosSafeHttpDataSource was logging full pre-signed googlevideo
      URLs (signature/sig/pot/expire/cpn) via raw android.util.Log.i
      (no DEBUG gate). Then LogDump.capture would scrape its own PID's
      logcat and ship it via the share sheet — a "report a bug to
      Telegram" silently exfiltrated session credentials. Fixed by
      switching all log calls to strawLogD/strawLogW (gated on
      BuildConfig.DEBUG), dropping the full-URL log entirely, and
      adding a regex scrub pass in LogDump for googlevideo URLs +
      signed-param keys before the file hits disk.
  S2  Downloader.enqueue handed signed googlevideo URLs to the system
      DownloadManager, where they leak into DM's SQLite, logcat, the
      system notification, and apps holding ACCESS_DOWNLOAD_MANAGER.
      Set VISIBILITY_HIDDEN + setVisibleInDownloadsUi(false) so the
      URL never surfaces in any external surface. Added the
      DOWNLOAD_WITHOUT_NOTIFICATION permission DM requires.
  S3  SettingsImport extracted the user's full newpipe.db (every sub,
      watch, search) into cacheDir, deleted on finally. A force-kill
      mid-import left the DB on disk indefinitely. Wrapped cleanup in
      withContext(NonCancellable), switched workDir to createTempFile
      (unguessable name), and added StrawApp.onCreate sweep of stale
      newpipe-import-* dirs on every cold start.
  C1  SearchViewModel.reactiveFilter ran a fresh SharedPreferences.
      getString + Json.decodeFromString on FeedCache (~225 KB) AND
      SearchCache (~150 KB) on EVERY keystroke. Hoisted into a
      MutableStateFlow<List<StreamItem>> pool, built once on
      Dispatchers.IO at VM init and refreshed after each successful
      submit. Reactive filter now walks an in-memory list.
  C2  SubscriptionFeedViewModel.init did the same FeedCache.load()
      synchronously on the main thread at construction (first compose
      pass blocked on the JSON decode). Moved into
      viewModelScope.launch + withContext(Dispatchers.IO).

HIGH — function correctness + defense in depth
  B1+B2  SearchScreen when-branch order: loading + error short-
         circuited before the results branch, hiding the cached
         preview the VM explicitly kept visible on cache-hit + on
         network failure. Refactored to render the cached list under
         a thin progress bar / error banner instead.
  B3   Downloads "tap completed row" silently failed since minSdk 24
       (FileUriExposedException on the file:// URI). Route through
       FileProvider with new file_paths.xml entries for
       Movies/audio + Movies/video.
  B6   Manifest VIEW intent-filter was missing music.youtube.com and
       youtube-nocookie.com hosts even though YT_HOSTS allowed them
       — added both.
  B7   SponsorBlockSkipLoop fired one Toast per skip with no rate
       limit; sponsor-dense videos painted 20+ Toasts over 40s after
       the seeks completed. 3s rate limit per cur.streamUrl.
  Q8   resolvePlayback.pickVideo fallback used maxByOrNull when the
       comment said "lowest available" — a 480p-capped user on a
       1080p-only upload got 1080p (their data cap blown). Switched
       to minByOrNull { height } when nothing fits the cap.
  S1   SettingsImport extractZip had no size or entry-count caps —
       zip-bomb could fill cacheDir. Added MAX_DB_BYTES (256 MB),
       MAX_PREFS_BYTES (1 MB), MAX_ZIP_ENTRIES (64). copyBounded /
       readBoundedBytes helpers replace the unbounded copyTo /
       readBytes.
  S2   Manifest: android:allowBackup=false +
       android:dataExtractionRules=@xml/data_extraction_rules.xml +
       android:fullBackupContent=false. Excludes root/file/database/
       sharedpref/external from both cloud-backup and device-transfer
       so the user's full search + watch history doesn't ride to
       Google Drive.
  S3   Dropped isLenient = true from every Json {} instance (7 sites
       across data/ and net/). Lenient parser was buying nothing on
       data we wrote ourselves and was a hardening gap on the third-
       party SponsorBlock + RYD endpoints (community-run; malformed
       payload could feed bad timestamps into the skip loop).
  S4   SubscriptionsStore.addAll + HistoryStore.recordAllWatches bulk
       methods, used by SettingsImport. Per-row toggle was O(N²) +
       N SP writes; bulk path is O(N) + 1 write.
  C3   SubsPane infinite-scroll LaunchedEffect keyed on
       (displayed.size, hasMore) — both mutated BY the effect. The
       collector cancelled itself mid-stream and dropped emissions,
       producing "scroll to bottom, nothing more loads". Re-keyed on
       listState + filteredCount; the collect lambda reads state
       through the snapshotFlow producer to avoid stale captures.
  C7   liveDrag + playbackSpeed: mutableFloatStateOf instead of
       mutableStateOf — no Float boxing on the 100Hz drag callback.
  C8   LogDump.capture is now suspend on Dispatchers.IO. The Settings
       click handler launches into scope; button shows "Exporting…"
       while in flight.

MED — cheap wins picked up in passing
  Q9   reactiveFilter clears the cached preview when current query no
       longer has any matches (was leaving stale results visible).
  Q10  Hide-watched filter excludes blank video IDs from watchedIds
       — a blank in the set used to match every malformed-URL feed
       item and silently hide them.
  Q13  PiP button in VideoDetail bails with a Toast on null
       controller OR null resolved playback (was falling through to
       enterPictureInPictureMode with no stream).
  C17  SpeedPickerDialog row used fillMaxSize inside an AlertDialog;
       only the first row got non-zero height. Fixed to fillMaxWidth.

Deferred to vc=36 follow-up (touch surface area we don't want to
churn in the same ship):
  - C6 atomic setPlayingFrom guard in StrawMediaController
  - S3 (full) — direct-streaming download replacing DownloadManager
  - MED-C16 LazyColumn refactor in VideoDetailScreen
  - MED-Q12 loadedUrl assignment ordering hardening
This commit is contained in:
Kayos 2026-05-25 13:27:30 -07:00
parent a776fbf2e4
commit e76a325faa
26 changed files with 531 additions and 166 deletions

View file

@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
// NewPipeExtractor in the runtime path. // NewPipeExtractor in the runtime path.
const val STRAW_VERSION_CODE = 34 const val STRAW_VERSION_CODE = 35
const val STRAW_VERSION_NAME = "0.1.0-AT" const val STRAW_VERSION_NAME = "0.1.0-AU"
const val STRAW_APPLICATION_ID = "com.sulkta.straw" const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -11,12 +11,20 @@
<!-- Wake while audio plays --> <!-- Wake while audio plays -->
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- DownloadManager Request.setNotificationVisibility(HIDDEN) requires
this permission. Used by Downloader so signed googlevideo URLs
don't surface in the system notification shade. -->
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
<application <application
android:name=".StrawApp" android:name=".StrawApp"
android:label="@string/app_name" android:label="@string/app_name"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="false"
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
@ -29,7 +37,10 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- Open YouTube URLs with Straw. --> <!-- Open YouTube URLs with Straw. Hosts here must stay in sync
with YT_HOSTS in StrawActivity.kt — drift was caught in the
vc=34 function audit (music.youtube.com etc. were accepted
by the code but never offered by the launcher disambig). -->
<intent-filter android:autoVerify="false"> <intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
@ -39,6 +50,9 @@
<data android:host="m.youtube.com" /> <data android:host="m.youtube.com" />
<data android:host="youtube.com" /> <data android:host="youtube.com" />
<data android:host="youtu.be" /> <data android:host="youtu.be" />
<data android:host="music.youtube.com" />
<data android:host="youtube-nocookie.com" />
<data android:host="www.youtube-nocookie.com" />
</intent-filter> </intent-filter>
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SEND" /> <action android:name="android.intent.action.SEND" />

View file

@ -207,7 +207,13 @@ class StrawActivity : ComponentActivity() {
// Explicit scheme + host check — defense in depth vs the // Explicit scheme + host check — defense in depth vs the
// manifest intent-filter; apps can synth intents that // manifest intent-filter; apps can synth intents that
// bypass filter scheme matching on exported activities. // 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 if (!looksLikeYouTube(data)) return null
data data
} }

View file

@ -12,6 +12,7 @@ import com.sulkta.straw.data.Playlists
import com.sulkta.straw.data.SearchCache import com.sulkta.straw.data.SearchCache
import com.sulkta.straw.data.Settings import com.sulkta.straw.data.Settings
import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.data.Subscriptions
import com.sulkta.straw.feature.dataimport.SettingsImport
class StrawApp : Application() { class StrawApp : Application() {
override fun onCreate() { override fun onCreate() {
@ -27,5 +28,10 @@ class StrawApp : Application() {
Playlists.init(this) Playlists.init(this)
FeedCache.init(this) FeedCache.init(this)
SearchCache.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)
} }
} }

View file

@ -280,8 +280,12 @@ private fun SubsPane(
var visibleCount by remember { mutableIntStateOf(PAGE_SIZE) } var visibleCount by remember { mutableIntStateOf(PAGE_SIZE) }
// O(1) lookup for the watched-filter; rebuild only when watches // O(1) lookup for the watched-filter; rebuild only when watches
// change. Just the video IDs because URLs vary by tracking params. // change. Drop blank IDs — `recordWatch` doesn't gate on those,
val watchedIds = remember(watches) { watches.map { it.videoId }.toSet() } // 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) { val filteredItems = remember(feed.items, hideWatched, watchedIds) {
if (!hideWatched) feed.items if (!hideWatched) feed.items
@ -388,11 +392,24 @@ private fun SubsPane(
lastVisible >= info.totalItemsCount - 5 lastVisible >= info.totalItemsCount - 5
} }
} }
LaunchedEffect(displayed.size, hasMore) { // Key on listState only — the previous key set
snapshotFlow { nearBottom }.collect { atEnd -> // (displayed.size, hasMore) was mutated BY this effect,
if (atEnd && hasMore) { // 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) visibleCount = (visibleCount + PAGE_SIZE)
.coerceAtMost(filteredItems.size) .coerceAtMost(filteredCount)
} }
} }
} }

View file

@ -36,7 +36,7 @@ private const val KEY = "cache_v1"
class FeedCacheStore(context: Context) { class FeedCacheStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) 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. */ /** Snapshot of the disk cache. Returns empty map if nothing saved. */
fun load(): Map<String, FeedCacheEntry> = runCatching { fun load(): Map<String, FeedCacheEntry> = runCatching {

View file

@ -36,7 +36,7 @@ private const val MAX_SEARCHES = 20
class HistoryStore(context: Context) { class HistoryStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) 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()) private val _watches = MutableStateFlow(loadWatches())
val watches: StateFlow<List<WatchHistoryItem>> = _watches.asStateFlow() val watches: StateFlow<List<WatchHistoryItem>> = _watches.asStateFlow()
@ -56,6 +56,30 @@ class HistoryStore(context: Context) {
sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply() sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply()
} }
/**
* Bulk import. Callers (currently SettingsImport) feed
* oldestnewest 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<WatchHistoryItem>) {
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) { fun recordSearch(query: String) {
val q = query.trim() val q = query.trim()
if (q.isEmpty()) return if (q.isEmpty()) return

View file

@ -47,7 +47,7 @@ private const val KEY = "playlists_v1"
class PlaylistsStore(context: Context) { class PlaylistsStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) 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()) private val _playlists = MutableStateFlow(load())
val playlists: StateFlow<List<Playlist>> = _playlists.asStateFlow() val playlists: StateFlow<List<Playlist>> = _playlists.asStateFlow()

View file

@ -37,7 +37,7 @@ private const val MAX_ITEMS_PER_QUERY = 20
class SearchCacheStore(context: Context) { class SearchCacheStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) 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<SearchCacheEntry> = runCatching { fun load(): List<SearchCacheEntry> = runCatching {
val s = sp.getString(KEY, null) ?: return emptyList() val s = sp.getString(KEY, null) ?: return emptyList()

View file

@ -29,7 +29,7 @@ private const val KEY = "subs_v1"
class SubscriptionsStore(context: Context) { class SubscriptionsStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) 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()) private val _subs = MutableStateFlow(load())
val subs: StateFlow<List<ChannelRef>> = _subs.asStateFlow() val subs: StateFlow<List<ChannelRef>> = _subs.asStateFlow()
@ -64,6 +64,31 @@ class SubscriptionsStore(context: Context) {
persist(next) persist(next)
} }
/**
* Bulk-add. Single persist instead of N. Per-call `toggle()` was
* O() + 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<ChannelRef>): 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() { fun clear() {
// Same atomic-update path as toggle — protects against a concurrent // Same atomic-update path as toggle — protects against a concurrent
// toggle racing the clear and persisting [new-item] after the // toggle racing the clear and persisting [new-item] after the

View file

@ -38,6 +38,7 @@ import com.sulkta.straw.data.WatchHistoryItem
import java.io.File import java.io.File
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
@ -106,10 +107,29 @@ object SettingsImport {
runCatching { runInner(context, zipUri) } 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<String>() val warnings = mutableListOf<String>()
val workDir = File(context.cacheDir, "newpipe-import-${System.currentTimeMillis()}") // createTempFile returns an unguessable name and 0600 perms by
workDir.mkdirs() // 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 { try {
val (dbFile, prefsJson) = extractZip(context, zipUri, workDir, warnings) val (dbFile, prefsJson) = extractZip(context, zipUri, workDir, warnings)
@ -132,9 +152,21 @@ object SettingsImport {
warnings = warnings, warnings = warnings,
) )
} finally { } finally {
// 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() 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( private fun extractZip(
context: Context, context: Context,
@ -144,25 +176,38 @@ object SettingsImport {
): Pair<File?, JsonObject?> { ): Pair<File?, JsonObject?> {
var dbFile: File? = null var dbFile: File? = null
var prefs: JsonObject? = null var prefs: JsonObject? = null
var entryCount = 0
context.contentResolver.openInputStream(zipUri)?.use { input -> context.contentResolver.openInputStream(zipUri)?.use { input ->
ZipInputStream(input).use { zip -> ZipInputStream(input).use { zip ->
while (true) { while (true) {
val entry = zip.nextEntry ?: break 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) { when (entry.name) {
"newpipe.db" -> { "newpipe.db" -> {
val out = File(workDir, "newpipe.db") val out = File(workDir, "newpipe.db")
out.outputStream().use { os -> val written = copyBounded(zip, out, MAX_DB_BYTES)
zip.copyTo(os, bufferSize = 64 * 1024) if (written < 0L) {
warnings += "newpipe.db exceeds ${MAX_DB_BYTES / (1024 * 1024)} MB — aborting"
out.delete()
return null to null
} }
dbFile = out dbFile = out
} }
"preferences.json" -> { "preferences.json" -> {
val bytes = zip.readBytes() val bytes = readBoundedBytes(zip, MAX_PREFS_BYTES)
if (bytes == null) {
warnings += "preferences.json exceeds ${MAX_PREFS_BYTES / 1024} KB — skipping"
} else {
prefs = runCatching { prefs = runCatching {
Json.parseToJsonElement(bytes.decodeToString()) as? JsonObject Json.parseToJsonElement(bytes.decodeToString()) as? JsonObject
}.getOrNull() }.getOrNull()
if (prefs == null) warnings += "preferences.json present but unparseable" if (prefs == null) warnings += "preferences.json present but unparseable"
} }
}
// newpipe.settings is the legacy XML form; preferences.json // newpipe.settings is the legacy XML form; preferences.json
// supersedes it in every modern export. Skip. // supersedes it in every modern export. Skip.
else -> { /* ignore other entries */ } else -> { /* ignore other entries */ }
@ -176,14 +221,51 @@ object SettingsImport {
return dbFile to prefs 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 data class SubsResult(val added: Int, val skipped: Int)
private fun importSubscriptions(dbFile: File): SubsResult { private fun importSubscriptions(dbFile: File): SubsResult {
val store = Subscriptions.get() 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 var skipped = 0
val staged = mutableListOf<ChannelRef>()
openDb(dbFile).use { db -> openDb(dbFile).use { db ->
db.rawQuery( db.rawQuery(
"SELECT url, name, avatar_url, service_id FROM subscriptions", "SELECT url, name, avatar_url, service_id FROM subscriptions LIMIT $maxRows",
null, null,
).use { c -> ).use { c ->
while (c.moveToNext()) { while (c.moveToNext()) {
@ -195,13 +277,12 @@ object SettingsImport {
val url = c.getString(0) ?: continue val url = c.getString(0) ?: continue
val name = c.getString(1) ?: continue val name = c.getString(1) ?: continue
val avatar = c.getString(2) val avatar = c.getString(2)
if (!store.isSubscribed(url)) { staged += ChannelRef(url = url, name = name, avatar = avatar)
store.toggle(ChannelRef(url = url, name = name, avatar = avatar))
added++
}
} }
} }
} }
// Single dedup + single persist regardless of N.
val added = store.addAll(staged)
return SubsResult(added, skipped) return SubsResult(added, skipped)
} }
@ -293,12 +374,17 @@ object SettingsImport {
db.rawQuery("SELECT COUNT(*) FROM stream_history", null).use { c -> db.rawQuery("SELECT COUNT(*) FROM stream_history", null).use { c ->
if (c.moveToNext()) watchesAvailable = c.getInt(0) 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<WatchHistoryItem>()
db.rawQuery( db.rawQuery(
""" """
SELECT s.url, s.title, s.uploader, s.thumbnail_url, h.access_date, s.service_id SELECT s.url, s.title, s.uploader, s.thumbnail_url, h.access_date, s.service_id
FROM stream_history h FROM stream_history h
JOIN streams s ON s.uid = h.stream_id JOIN streams s ON s.uid = h.stream_id
ORDER BY h.access_date ASC ORDER BY h.access_date ASC
LIMIT 50000
""".trimIndent(), """.trimIndent(),
null, null,
).use { c -> ).use { c ->
@ -309,19 +395,18 @@ object SettingsImport {
val uploader = c.getString(2) ?: "" val uploader = c.getString(2) ?: ""
val thumb = c.getString(3) val thumb = c.getString(3)
val videoId = extractYtVideoId(url) ?: continue val videoId = extractYtVideoId(url) ?: continue
historyStore.recordWatch( staged += WatchHistoryItem(
WatchHistoryItem(
url = url, url = url,
videoId = videoId, videoId = videoId,
title = title, title = title,
uploader = uploader, uploader = uploader,
thumbnail = thumb, thumbnail = thumb,
watchedAt = c.getLong(4), watchedAt = c.getLong(4),
),
) )
watchesSeen++ watchesSeen++
} }
} }
historyStore.recordAllWatches(staged)
// Resume positions — counted, not stored. Future task hooks into // Resume positions — counted, not stored. Future task hooks into
// a ResumePositionsStore. // a ResumePositionsStore.

View file

@ -65,6 +65,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@ -155,7 +156,9 @@ fun VideoDetailScreen(
val dismissThresholdPx = with(density) { 140.dp.toPx() } val dismissThresholdPx = with(density) { 140.dp.toPx() }
val flingVelocityThreshold = with(density) { 600.dp.toPx() } val flingVelocityThreshold = with(density) { 600.dp.toPx() }
val screenHeightPx = with(density) { configuration.screenHeightDp.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) } var dragging by remember { mutableStateOf(false) }
val releaseAnim = remember { Animatable(0f) } val releaseAnim = remember { Animatable(0f) }
val draggableState = rememberDraggableState { delta -> val draggableState = rememberDraggableState { delta ->
@ -385,13 +388,16 @@ fun VideoDetailScreen(
Toast.makeText(context, "PiP needs Android 8+", Toast.LENGTH_SHORT).show() Toast.makeText(context, "PiP needs Android 8+", Toast.LENGTH_SHORT).show()
return@OutlinedButton return@OutlinedButton
} }
// PiP needs the controller to actually be playing // PiP into nothing isn't useful — bail with a
// this video, same as Background — otherwise we // Toast if there's no controller / no resolved
// pop out into nothing. // playback to push into it. vc=34 audit Q-13.
val c = controller val c = controller
if (c != null && NowPlaying.current.value?.streamUrl != streamUrl) {
val r = state.resolved val r = state.resolved
if (r != null) { 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( c.setPlayingFrom(
streamUrl = streamUrl, streamUrl = streamUrl,
title = d.title, title = d.title,
@ -400,7 +406,6 @@ fun VideoDetailScreen(
resolved = r, resolved = r,
) )
} }
}
val params = PictureInPictureParams.Builder() val params = PictureInPictureParams.Builder()
.setAspectRatio(Rational(16, 9)) .setAspectRatio(Rational(16, 9))
.build() .build()

View file

@ -195,13 +195,22 @@ class VideoDetailViewModel : ViewModel() {
segments: List<SbSegment>, segments: List<SbSegment>,
): ResolvedPlayback { ): ResolvedPlayback {
val maxRes = Settings.get().maxResolution.value.ceiling val maxRes = Settings.get().maxResolution.value.ceiling
// Filter by max-resolution ceiling but fall back to the lowest // Pick the highest-bitrate stream that still fits the user's
// available if the ceiling excludes everything (e.g. a 360p-only // cap. Fallback: when every available stream EXCEEDS the cap
// upload with the user on a 480p 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<uniffi.strawcore.VideoStreamItem>): String? { fun pickVideo(streams: List<uniffi.strawcore.VideoStreamItem>): String? {
if (streams.isEmpty()) return null if (streams.isEmpty()) return null
val pool = streams.filter { it.height <= maxRes }.ifEmpty { streams } val capped = streams.filter { it.height <= maxRes }
return pool.maxByOrNull { it.bitrate }?.url return if (capped.isNotEmpty()) {
capped.maxByOrNull { it.bitrate }?.url
} else {
streams.minByOrNull { it.height }?.url
}
} }
return ResolvedPlayback( return ResolvedPlayback(
title = info.title, title = info.title,

View file

@ -51,11 +51,24 @@ object Downloader {
val filename = "$safeTitle${kind.ext}" val filename = "$safeTitle${kind.ext}"
val dm = ctx.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager 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 { val req = runCatching {
DownloadManager.Request(Uri.parse(url)) DownloadManager.Request(Uri.parse(url))
.setTitle(title) .setTitle(title)
.setDescription("Straw — ${kind.name.lowercase()}") .setDescription("Straw — ${kind.name.lowercase()}")
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) .setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN)
.setVisibleInDownloadsUi(false)
.setAllowedOverMetered(true) .setAllowedOverMetered(true)
.setAllowedOverRoaming(true) .setAllowedOverRoaming(true)
.setDestinationInExternalFilesDir( .setDestinationInExternalFilesDir(

View file

@ -156,8 +156,27 @@ private fun DownloadRowView(
.fillMaxWidth() .fillMaxWidth()
.clickable(enabled = openable) { .clickable(enabled = openable) {
row.localUri?.let { uri -> 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 { val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(Uri.parse(uri), row.mediaType ?: "*/*") setDataAndType(shareUri, row.mediaType ?: "*/*")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} }
runCatching { context.startActivity(intent) } runCatching { context.startActivity(intent) }

View file

@ -66,14 +66,14 @@ class SubscriptionFeedViewModel : ViewModel() {
init { init {
// Hydrate from disk and immediately render the cached items so // Hydrate from disk and immediately render the cached items so
// the Subs tab paints in one frame instead of after the network // the Subs tab paints before the network round-trip resolves.
// round-trip. The refresh that follows replaces stale entries // vc=34 audit CRIT: previously this ran synchronously on the
// in-place — items animate to their new positions via LazyColumn // main thread at VM construction, blocking the first compose
// key stability (URLs are stable across fetches). // pass on a ~225 KB Json.decodeFromString.
// Skip the hydrate when the user has disabled caching — they viewModelScope.launch {
// explicitly don't want disk usage for this. if (!Settings.get().cacheEnabled.value) return@launch
val saved = if (Settings.get().cacheEnabled.value) FeedCache.get().load() else emptyMap() val saved = withContext(Dispatchers.IO) { FeedCache.get().load() }
if (saved.isNotEmpty()) { if (saved.isEmpty()) return@launch
channelCache.putAll(saved) channelCache.putAll(saved)
val channels = Subscriptions.get().subs.value val channels = Subscriptions.get().subs.value
if (channels.isNotEmpty()) { if (channels.isNotEmpty()) {

View file

@ -52,6 +52,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@ -89,7 +90,7 @@ fun PlayerScreen(
val state by vm.ui.collectAsStateWithLifecycle() val state by vm.ui.collectAsStateWithLifecycle()
LaunchedEffect(streamUrl) { vm.load(streamUrl) } 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 audioOnly by remember { mutableStateOf(false) }
var showSpeedDialog by remember { mutableStateOf(false) } var showSpeedDialog by remember { mutableStateOf(false) }
@ -301,7 +302,7 @@ private fun SpeedPickerDialog(
options.forEach { s -> options.forEach { s ->
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxWidth()
.clickable { onPick(s) } .clickable { onPick(s) }
.padding(vertical = 12.dp), .padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -341,6 +342,10 @@ fun SponsorBlockSkipLoop() {
val segments = cur.segments val segments = cur.segments
if (segments.isEmpty() || controller == null) return if (segments.isEmpty() || controller == null) return
val skipped = remember(cur.streamUrl) { mutableSetOf<String>() } val skipped = remember(cur.streamUrl) { mutableSetOf<String>() }
// 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) { LaunchedEffect(cur.streamUrl, controller) {
while (true) { while (true) {
delay(150) delay(150)
@ -360,7 +365,11 @@ fun SponsorBlockSkipLoop() {
controller.seekTo(targetMs) controller.seekTo(targetMs)
} }
s.UUID?.let { skipped.add(it) } s.UUID?.let { skipped.add(it) }
val now = System.currentTimeMillis()
if (now - lastToastAt > 3000) {
Toast.makeText(context, "skipped ${s.category}", Toast.LENGTH_SHORT).show() Toast.makeText(context, "skipped ${s.category}", Toast.LENGTH_SHORT).show()
lastToastAt = now
}
} }
} }
} }

View file

@ -69,12 +69,19 @@ fun SearchScreen(
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
when { 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(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { CircularProgressIndicator() } ) { 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), modifier = Modifier.fillMaxSize().padding(16.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@ -117,6 +124,20 @@ fun SearchScreen(
) { Text("hit enter to search") } ) { Text("hit enter to search") }
else -> Column(modifier = Modifier.fillMaxSize()) { 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) { if (state.fromCache) {
Text( Text(
text = if (state.loading) "Cached results · refreshing…" text = if (state.loading) "Cached results · refreshing…"

View file

@ -49,12 +49,34 @@ class SearchViewModel : ViewModel() {
private val _ui = MutableStateFlow(SearchUiState()) private val _ui = MutableStateFlow(SearchUiState())
val ui: StateFlow<SearchUiState> = _ui.asStateFlow() val ui: StateFlow<SearchUiState> = _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<List<StreamItem>>(emptyList())
init {
viewModelScope.launch {
if (Settings.get().cacheEnabled.value) {
pool.value = withContext(Dispatchers.IO) { buildPool() }
}
}
}
private fun buildPool(): List<StreamItem> = 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) { fun onQueryChange(q: String) {
_ui.value = _ui.value.copy(query = q) _ui.value = _ui.value.copy(query = q)
// Reactive filter: scan every cached item (search-cache + subs // Reactive filter: scan the in-memory `pool` as the user types.
// feed-cache) as the user types. Cheap, runs in-memory, gives // Pool is a List<StreamItem> walked once per keystroke — bounded
// instant feedback before they hit Enter. Disabled when the // (~1500 items typical), no disk I/O, no JSON decode.
// user has turned off the cache feature.
if (Settings.get().cacheEnabled.value && q.trim().length >= 2) { if (Settings.get().cacheEnabled.value && q.trim().length >= 2) {
val matches = reactiveFilter(q.trim()) val matches = reactiveFilter(q.trim())
if (matches.isNotEmpty()) { if (matches.isNotEmpty()) {
@ -64,6 +86,11 @@ class SearchViewModel : ViewModel() {
loading = false, loading = false,
error = null, 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()) { } else if (q.isBlank()) {
// Clear cached preview if the box is cleared. // Clear cached preview if the box is cleared.
@ -126,6 +153,10 @@ class SearchViewModel : ViewModel() {
if (Settings.get().cacheEnabled.value) { if (Settings.get().cacheEnabled.value) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
runCatching { SearchCache.get().record(q, items) } 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) { } catch (t: Throwable) {
@ -140,24 +171,18 @@ class SearchViewModel : ViewModel() {
} }
/** /**
* Walk the merged corpus of cached items (every saved search + * Walk the in-memory `pool` and return items whose title or uploader
* every subs-feed channel cache) and return items whose title or * contains the query. Case-insensitive, capped at 60 results.
* uploader contains the query case-insensitive, dedup by URL. * No disk I/O on the hot path `pool` is refreshed off-thread
* Cheap: even with 30 cached queries * 20 items + 30 channels * 30 * after each successful submit and at VM construction.
* items it's < 1500 records, plenty fast for an in-memory filter.
*/ */
private fun reactiveFilter(q: String): List<StreamItem> { private fun reactiveFilter(q: String): List<StreamItem> {
val needle = q.lowercase() val needle = q.lowercase()
val pool = buildList<StreamItem> { return pool.value.asSequence()
SearchCache.get().load().forEach { addAll(it.items) }
FeedCache.get().load().values.forEach { addAll(it.items) }
}
return pool.asSequence()
.filter { item -> .filter { item ->
item.title.lowercase().contains(needle) item.title.lowercase().contains(needle)
|| item.uploader.lowercase().contains(needle) || item.uploader.lowercase().contains(needle)
} }
.distinctBy { it.url }
.take(60) .take(60)
.toList() .toList()
} }

View file

@ -244,10 +244,18 @@ fun SettingsScreen() {
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(onClick = { var logDumping by remember { mutableStateOf(false) }
OutlinedButton(
enabled = !logDumping,
onClick = {
logDumping = true
scope.launch {
val outcome = LogDump.capture(context) val outcome = LogDump.capture(context)
logDumping = false
outcome.onSuccess { intent -> outcome.onSuccess { intent ->
context.startActivity(android.content.Intent.createChooser(intent, "Share Straw logs")) context.startActivity(
android.content.Intent.createChooser(intent, "Share Straw logs"),
)
} }
outcome.onFailure { t -> outcome.onFailure { t ->
Toast.makeText( Toast.makeText(
@ -256,8 +264,10 @@ fun SettingsScreen() {
Toast.LENGTH_LONG, Toast.LENGTH_LONG,
).show() ).show()
} }
}) { }
Text("Export logs…") },
) {
Text(if (logDumping) "Exporting…" else "Export logs…")
} }
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))

View file

@ -21,11 +21,12 @@
package com.sulkta.straw.net package com.sulkta.straw.net
import android.util.Log
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSpec import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.HttpDataSource import androidx.media3.datasource.HttpDataSource
import com.sulkta.straw.util.strawLogD
import com.sulkta.straw.util.strawLogW
private const val TAG = "IosSafeDS" private const val TAG = "IosSafeDS"
@ -60,17 +61,19 @@ class IosSafeHttpDataSource(
// come out as `bytes=N-M` (closed, accepted by googlevideo iOS URLs) // come out as `bytes=N-M` (closed, accepted by googlevideo iOS URLs)
// instead of `bytes=N-` (open, rejected with 403). // instead of `bytes=N-` (open, rejected with 403).
val bounded = dataSpec.buildUpon().setLength(requestLen).build() 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 u = dataSpec.uri
val itag = u.getQueryParameter("itag") val itag = u.getQueryParameter("itag")
val mime = u.getQueryParameter("mime") val mime = u.getQueryParameter("mime")
Log.i( strawLogD(TAG) {
TAG, "open: host=${u.host} itag=$itag mime=$mime " +
"open: pos=${bounded.position} len=${bounded.length} " + "pos=${bounded.position} len=${bounded.length} " +
"(origLen=${dataSpec.length}, chunkBytes=$chunkBytes) " + "(origLen=${dataSpec.length}, chunkBytes=$chunkBytes)"
"itag=$itag mime=$mime host=${u.host}", }
)
Log.i(TAG, "open url=${u.toString()}")
originalSpec = dataSpec originalSpec = dataSpec
totalRead = 0 totalRead = 0
// inner.open() returns the BOUNDED chunk's length. Track it so we // inner.open() returns the BOUNDED chunk's length. Track it so we
@ -78,10 +81,10 @@ class IosSafeHttpDataSource(
chunkRemaining = try { chunkRemaining = try {
inner.open(bounded) inner.open(bounded)
} catch (t: Throwable) { } catch (t: Throwable) {
Log.w(TAG, "open failed: ${t.javaClass.simpleName}: ${t.message}") strawLogW(TAG, t) { "open failed: ${t.javaClass.simpleName}" }
throw t 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 — // Report the original (potentially unbounded) length to the caller —
// ExoPlayer cares about the overall length, not our internal chunking. // ExoPlayer cares about the overall length, not our internal chunking.
return if (dataSpec.length == C.LENGTH_UNSET.toLong()) { return if (dataSpec.length == C.LENGTH_UNSET.toLong()) {

View file

@ -25,7 +25,7 @@ data class RydVotes(
object RydClient { object RydClient {
private const val TAG = "StrawRyd" private const val TAG = "StrawRyd"
private val json = Json { ignoreUnknownKeys = true; isLenient = true } private val json = Json { ignoreUnknownKeys = true }
/** Blocking — call from Dispatchers.IO. */ /** Blocking — call from Dispatchers.IO. */
fun fetch(videoId: String): RydVotes? { fun fetch(videoId: String): RydVotes? {

View file

@ -34,7 +34,7 @@ data class SbSegment(
object SponsorBlockClient { object SponsorBlockClient {
private const val TAG = "StrawSb" private const val TAG = "StrawSb"
private val json = Json { ignoreUnknownKeys = true; isLenient = true } private val json = Json { ignoreUnknownKeys = true }
fun fetch( fun fetch(
videoId: String, videoId: String,

View file

@ -6,10 +6,14 @@
* Used from the Settings "Export logs" action so users can attach a * Used from the Settings "Export logs" action so users can attach a
* log dump when reporting a problem. * log dump when reporting a problem.
* *
* NOTE: Android limits logcat-via-Runtime.exec to the calling app's * SECURITY: The dump is filtered before being written to disk
* own UID on API 30+, so this captures Straw's own log lines only * pre-signed googlevideo URLs, OAuth-style tokens, and anything
* (plus a sliver of system-wide messages tagged by our PID). No * matching the leak patterns below get scrubbed line-by-line. Without
* other app's logs are exposed. * 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 package com.sulkta.straw.util
@ -24,46 +28,58 @@ import java.io.IOException
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
object LogDump { object LogDump {
/** /**
* Pull recent logcat, write to a file in cacheDir, return a * Pull recent logcat, scrub sensitive substrings, write to a file
* share-able Intent. Caller is responsible for `startActivity`. * in cacheDir, return a share-able Intent. Suspend so callers can
* Returns null when the dump command itself fails the caller * stay off the main thread `proc.waitFor()` plus a multi-MB
* shows a Toast with the error. * `copyTo` is firmly an IO operation.
*/ */
fun capture(context: Context): Result<Intent> = runCatching { suspend fun capture(context: Context): Result<Intent> = withContext(Dispatchers.IO) {
runCatching {
val pid = Process.myPid() val pid = Process.myPid()
val timestamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) val timestamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
val outFile = File(context.cacheDir, "straw-logs-$timestamp.txt") val outFile = File(context.cacheDir, "straw-logs-$timestamp.txt")
val tmpFile = File(context.cacheDir, "straw-logs-$timestamp.txt.tmp")
// 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() }
// -d dump-and-exit (no follow), -v threadtime is the // -d dump-and-exit (no follow), -v threadtime is the
// most-greppable format, and the --pid filter restricts to // most-greppable format, --pid filter restricts to our
// our process so we don't accidentally exfiltrate sibling // process so we don't exfiltrate sibling apps' chatter.
// apps' chatter even when the system would allow it. val cmd = arrayOf("logcat", "-d", "-v", "threadtime", "--pid=$pid")
val cmd = arrayOf( val proc = ProcessBuilder(*cmd).redirectErrorStream(true).start()
"logcat", tmpFile.bufferedWriter().use { out ->
"-d", proc.inputStream.bufferedReader().useLines { lines ->
"-v", lines.forEach { line ->
"threadtime", out.write(scrubLine(line))
"--pid=$pid", out.newLine()
) }
val proc = ProcessBuilder(*cmd) }
.redirectErrorStream(true)
.start()
outFile.outputStream().use { out ->
proc.inputStream.copyTo(out)
} }
val exit = proc.waitFor() val exit = proc.waitFor()
if (exit != 0) { if (exit != 0) {
tmpFile.delete()
throw IOException("logcat exit=$exit") throw IOException("logcat exit=$exit")
} }
if (outFile.length() == 0L) { if (tmpFile.length() == 0L) {
tmpFile.delete()
throw IOException("logcat produced 0 bytes (sandbox restriction?)") 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")
}
// FileProvider authority — declared in AndroidManifest below.
val authority = "${context.packageName}.fileprovider" val authority = "${context.packageName}.fileprovider"
val uri: Uri = FileProvider.getUriForFile(context, authority, outFile) val uri: Uri = FileProvider.getUriForFile(context, authority, outFile)
Intent(Intent.ACTION_SEND).apply { Intent(Intent.ACTION_SEND).apply {
@ -73,4 +89,28 @@ object LogDump {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) 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://<host>.googlevideo.com/<scrubbed>")
// Any remaining signed-param shapes that snuck through other URLs.
s = SIGNED_PARAM_RE.replace(s, "$1=<scrubbed>")
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,
)
} }

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Block both cloud auto-backup (`cloud-backup`) and direct device-to-
device transfers (`device-transfer`) for every Straw storage scope.
Watch history, search history, full subscription list, and the on-
disk feed/search caches would otherwise sync silently to the user's
Google account and ride to any restored device.
We don't WANT this content backed up — there's no account model;
there's nothing to recover. Better to ask the user to re-subscribe
than to leak their entire video-watching profile to Google Drive.
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</cloud-backup>
<device-transfer>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</device-transfer>
</data-extraction-rules>

View file

@ -1,8 +1,14 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android"> <paths xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Used by LogDump for sharing logcat captures to a chooser-picked <!-- LogDump shares logcat captures to a chooser-picked app. Limited
app (mail, Telegram, Signal, etc.). Limited to cacheDir so a to cacheDir so a pasted URI can't grant access to user files. -->
pasted URI can't grant the receiving app access to arbitrary
user files. -->
<cache-path name="logs" path="." /> <cache-path name="logs" path="." />
<!-- Completed downloads. Downloader uses
setDestinationInExternalFilesDir(DIRECTORY_MOVIES + "/audio" |
"/video"), so the FileProvider needs to be able to map those
paths back to a content:// URI when DownloadsScreen taps to
open the finished file. -->
<external-files-path name="downloads-audio" path="Movies/audio/" />
<external-files-path name="downloads-video" path="Movies/video/" />
</paths> </paths>