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
// 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"

View file

@ -11,12 +11,20 @@
<!-- Wake while audio plays -->
<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
android:name=".StrawApp"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="false"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
@ -29,7 +37,10 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</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">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@ -39,6 +50,9 @@
<data android:host="m.youtube.com" />
<data android:host="youtube.com" />
<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>
<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
// 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
}

View file

@ -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)
}
}

View file

@ -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)
}
}
}

View file

@ -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<String, FeedCacheEntry> = runCatching {

View file

@ -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<List<WatchHistoryItem>> = _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
* 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) {
val q = query.trim()
if (q.isEmpty()) return

View file

@ -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<List<Playlist>> = _playlists.asStateFlow()

View file

@ -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<SearchCacheEntry> = runCatching {
val s = sp.getString(KEY, null) ?: return emptyList()

View file

@ -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<List<ChannelRef>> = _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 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() {
// Same atomic-update path as toggle — protects against a concurrent
// 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.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<String>()
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<File?, JsonObject?> {
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<ChannelRef>()
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<WatchHistoryItem>()
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.

View file

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

View file

@ -195,13 +195,22 @@ class VideoDetailViewModel : ViewModel() {
segments: List<SbSegment>,
): 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<uniffi.strawcore.VideoStreamItem>): 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,

View file

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

View file

@ -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) }

View file

@ -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()) {

View file

@ -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<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) {
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
}
}
}
}

View file

@ -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…"

View file

@ -49,12 +49,34 @@ class SearchViewModel : ViewModel() {
private val _ui = MutableStateFlow(SearchUiState())
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) {
_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<StreamItem> 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<StreamItem> {
val needle = q.lowercase()
val pool = buildList<StreamItem> {
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()
}

View file

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

View file

@ -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()) {

View file

@ -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? {

View file

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

View file

@ -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<Intent> = 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<Intent> = 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://<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"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Used by LogDump for sharing logcat captures to a chooser-picked
app (mail, Telegram, Signal, etc.). Limited to cacheDir so a
pasted URI can't grant the receiving app access to arbitrary
user files. -->
<!-- LogDump shares logcat captures to a chooser-picked app. Limited
to cacheDir so a pasted URI can't grant access to user files. -->
<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>