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:
parent
a776fbf2e4
commit
e76a325faa
26 changed files with 531 additions and 166 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* 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<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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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²) + 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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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…"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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? {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
28
strawApp/src/main/res/xml/data_extraction_rules.xml
Normal file
28
strawApp/src/main/res/xml/data_extraction_rules.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue