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 @@ - + + + + +