diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt
index 9319ebcd7..1b854d719 100644
--- a/buildSrc/src/main/kotlin/ProjectConfig.kt
+++ b/buildSrc/src/main/kotlin/ProjectConfig.kt
@@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
// NewPipeExtractor in the runtime path.
-const val STRAW_VERSION_CODE = 33
-const val STRAW_VERSION_NAME = "0.1.0-AS"
+const val STRAW_VERSION_CODE = 34
+const val STRAW_VERSION_NAME = "0.1.0-AT"
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
diff --git a/strawApp/src/main/AndroidManifest.xml b/strawApp/src/main/AndroidManifest.xml
index 87ff57336..d3d86d3e0 100644
--- a/strawApp/src/main/AndroidManifest.xml
+++ b/strawApp/src/main/AndroidManifest.xml
@@ -60,5 +60,16 @@
+
+
+
+
+
diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt
index 2a5cc65e9..86bc9f577 100644
--- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt
@@ -25,6 +25,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.media3.common.util.UnstableApi
+import com.sulkta.straw.data.Settings
+import com.sulkta.straw.data.ThemeMode
import com.sulkta.straw.feature.channel.ChannelScreen
import com.sulkta.straw.feature.detail.VideoDetailScreen
import com.sulkta.straw.feature.download.DownloadsScreen
@@ -67,7 +69,16 @@ class StrawActivity : ComponentActivity() {
val startUrl = pickYouTubeUrl(intent)
setContent {
- val scheme = if (isSystemInDarkTheme()) strawDarkColors() else strawLightColors()
+ // Theme picker: System follows OS, Light/Dark force the
+ // matching scheme regardless of system setting.
+ val themeMode by Settings.get().themeMode.collectAsState()
+ val systemDark = isSystemInDarkTheme()
+ val dark = when (themeMode) {
+ ThemeMode.System -> systemDark
+ ThemeMode.Light -> false
+ ThemeMode.Dark -> true
+ }
+ val scheme = if (dark) strawDarkColors() else strawLightColors()
// One MediaController for the whole activity. Every screen pulls
// it via LocalStrawController; the minibar overlay below uses it
// too. Single player, single source of truth.
diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt
index 95be0a44e..df0e041e1 100644
--- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt
@@ -9,6 +9,7 @@ import android.app.Application
import com.sulkta.straw.data.FeedCache
import com.sulkta.straw.data.History
import com.sulkta.straw.data.Playlists
+import com.sulkta.straw.data.SearchCache
import com.sulkta.straw.data.Settings
import com.sulkta.straw.data.Subscriptions
@@ -25,5 +26,6 @@ class StrawApp : Application() {
Subscriptions.init(this)
Playlists.init(this)
FeedCache.init(this)
+ SearchCache.init(this)
}
}
diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt
new file mode 100644
index 000000000..7dc05cc62
--- /dev/null
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt
@@ -0,0 +1,81 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Sulkta-Coop
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * Search-result cache. Holds the last N executed queries and their
+ * result lists so:
+ * - Re-running a recent query paints from cache in one frame.
+ * - Reactive-as-you-type filtering can scan all cached items as
+ * the user types, surfacing matches before they hit Search.
+ *
+ * Sized for SharedPreferences: 30 queries * 20 items each * ~250 bytes
+ * = ~150 KB worst case.
+ *
+ * Skips entirely when Settings.cacheEnabled is false — caller checks
+ * the flag before reading/writing.
+ */
+
+package com.sulkta.straw.data
+
+import android.content.Context
+import android.content.SharedPreferences
+import com.sulkta.straw.feature.search.StreamItem
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+
+@Serializable
+data class SearchCacheEntry(
+ val query: String,
+ val fetchedAt: Long,
+ val items: List,
+)
+
+private const val PREFS = "straw_search_cache"
+private const val KEY = "search_v1"
+private const val MAX_QUERIES = 30
+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 }
+
+ fun load(): List = runCatching {
+ val s = sp.getString(KEY, null) ?: return emptyList()
+ json.decodeFromString>(s)
+ }.getOrDefault(emptyList())
+
+ /**
+ * Record a freshly-fetched query result. Idempotent: a re-run of
+ * the same query overwrites the prior entry rather than duplicating.
+ * Oldest entries fall off when MAX_QUERIES is exceeded.
+ */
+ fun record(query: String, items: List) {
+ val q = query.trim()
+ if (q.isEmpty() || items.isEmpty()) return
+ val capped = items.take(MAX_ITEMS_PER_QUERY)
+ val now = System.currentTimeMillis()
+ val current = load()
+ val without = current.filterNot { it.query.equals(q, ignoreCase = true) }
+ val next = (listOf(SearchCacheEntry(q, now, capped)) + without).take(MAX_QUERIES)
+ sp.edit().putString(KEY, json.encodeToString(next)).apply()
+ }
+
+ fun clear() {
+ sp.edit().remove(KEY).apply()
+ }
+}
+
+object SearchCache {
+ @Volatile private var instance: SearchCacheStore? = null
+
+ fun init(context: Context) {
+ if (instance == null) {
+ synchronized(this) {
+ if (instance == null) instance = SearchCacheStore(context.applicationContext)
+ }
+ }
+ }
+
+ fun get(): SearchCacheStore = instance
+ ?: error("SearchCacheStore not initialized — call SearchCache.init(context)")
+}
diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt
index 484cfdfe8..499ace926 100644
--- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt
@@ -37,9 +37,17 @@ enum class MaxResolution(val label: String, val ceiling: Int) {
P144("144p", 144),
}
+enum class ThemeMode(val label: String) {
+ System("Follow system"),
+ Light("Light"),
+ Dark("Dark"),
+}
+
private const val PREFS = "straw_settings"
private const val KEY_SB_CATS = "sb_categories_v1"
private const val KEY_MAX_RES = "max_resolution_v1"
+private const val KEY_THEME = "theme_mode_v1"
+private const val KEY_CACHE_ENABLED = "cache_enabled_v1"
class SettingsStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@@ -50,6 +58,12 @@ class SettingsStore(context: Context) {
private val _maxResolution = MutableStateFlow(loadMaxResolution())
val maxResolution: StateFlow = _maxResolution.asStateFlow()
+ private val _themeMode = MutableStateFlow(loadThemeMode())
+ val themeMode: StateFlow = _themeMode.asStateFlow()
+
+ private val _cacheEnabled = MutableStateFlow(sp.getBoolean(KEY_CACHE_ENABLED, true))
+ val cacheEnabled: StateFlow = _cacheEnabled.asStateFlow()
+
fun toggle(cat: SbCategory) {
// Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore.
val next = _sbCategories.updateAndGet { cur ->
@@ -63,6 +77,16 @@ class SettingsStore(context: Context) {
sp.edit().putString(KEY_MAX_RES, r.name).apply()
}
+ fun setThemeMode(t: ThemeMode) {
+ _themeMode.value = t
+ sp.edit().putString(KEY_THEME, t.name).apply()
+ }
+
+ fun setCacheEnabled(enabled: Boolean) {
+ _cacheEnabled.value = enabled
+ sp.edit().putBoolean(KEY_CACHE_ENABLED, enabled).apply()
+ }
+
private fun loadCategories(): Set {
val raw = sp.getStringSet(KEY_SB_CATS, null)
return if (raw == null) {
@@ -77,6 +101,11 @@ class SettingsStore(context: Context) {
val name = sp.getString(KEY_MAX_RES, null) ?: return MaxResolution.Auto
return MaxResolution.entries.firstOrNull { it.name == name } ?: MaxResolution.Auto
}
+
+ private fun loadThemeMode(): ThemeMode {
+ val name = sp.getString(KEY_THEME, null) ?: return ThemeMode.System
+ return ThemeMode.entries.firstOrNull { it.name == name } ?: ThemeMode.System
+ }
}
object Settings {
diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt
index 03eb764f7..2503a828c 100644
--- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt
@@ -21,6 +21,7 @@ import androidx.lifecycle.viewModelScope
import com.sulkta.straw.data.ChannelRef
import com.sulkta.straw.data.FeedCache
import com.sulkta.straw.data.FeedCacheEntry
+import com.sulkta.straw.data.Settings
import com.sulkta.straw.data.Subscriptions
import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.strawLogW
@@ -69,7 +70,9 @@ class SubscriptionFeedViewModel : ViewModel() {
// 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).
- val saved = FeedCache.get().load()
+ // 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()) {
channelCache.putAll(saved)
val channels = Subscriptions.get().subs.value
@@ -149,8 +152,11 @@ class SubscriptionFeedViewModel : ViewModel() {
// Persist what we just freshened. Off the main thread —
// JSON encode on 30 subs * 30 items is small but not
// free, and SharedPreferences.apply is async anyway.
- withContext(Dispatchers.IO) {
- runCatching { FeedCache.get().save(channelCache.toMap()) }
+ // Skipped entirely when the user has disabled caching.
+ if (Settings.get().cacheEnabled.value) {
+ withContext(Dispatchers.IO) {
+ runCatching { FeedCache.get().save(channelCache.toMap()) }
+ }
}
} catch (t: Throwable) {
_ui.update {
diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt
index a6809ebc4..fce5263c0 100644
--- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt
@@ -116,10 +116,21 @@ fun SearchScreen(
contentAlignment = Alignment.Center,
) { Text("hit enter to search") }
- else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
- items(state.results) { item ->
- ResultRow(item = item) { onOpenVideo(item.url, item.title) }
- HorizontalDivider()
+ else -> Column(modifier = Modifier.fillMaxSize()) {
+ if (state.fromCache) {
+ Text(
+ text = if (state.loading) "Cached results · refreshing…"
+ else "Cached results · hit Search for fresh",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(bottom = 4.dp),
+ )
+ }
+ LazyColumn(modifier = Modifier.fillMaxSize()) {
+ items(state.results) { item ->
+ ResultRow(item = item) { onOpenVideo(item.url, item.title) }
+ HorizontalDivider()
+ }
}
}
}
diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt
index 0b3cc78b3..a816c18b2 100644
--- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt
@@ -7,17 +7,28 @@ package com.sulkta.straw.feature.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.sulkta.straw.data.FeedCache
import com.sulkta.straw.data.History
+import com.sulkta.straw.data.SearchCache
+import com.sulkta.straw.data.Settings
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
data class SearchUiState(
val query: String = "",
val results: List = emptyList(),
val loading: Boolean = false,
val error: String? = null,
+ /**
+ * True when the visible results came from the local cache and we
+ * have not yet replaced them with a network response. Lets the UI
+ * show a faint "from cache" hint without blocking the list.
+ */
+ val fromCache: Boolean = false,
)
@kotlinx.serialization.Serializable
@@ -40,12 +51,53 @@ class SearchViewModel : ViewModel() {
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.
+ if (Settings.get().cacheEnabled.value && q.trim().length >= 2) {
+ val matches = reactiveFilter(q.trim())
+ if (matches.isNotEmpty()) {
+ _ui.value = _ui.value.copy(
+ results = matches,
+ fromCache = true,
+ loading = false,
+ error = null,
+ )
+ }
+ } else if (q.isBlank()) {
+ // Clear cached preview if the box is cleared.
+ _ui.value = _ui.value.copy(results = emptyList(), fromCache = false)
+ }
}
fun submit() {
val q = _ui.value.query.trim()
if (q.isEmpty()) return
- _ui.value = _ui.value.copy(loading = true, error = null, results = emptyList())
+
+ // Cache hit on submit: show immediately, kick off a refresh
+ // behind it so the user gets fresh items shortly after.
+ val cached = if (Settings.get().cacheEnabled.value) {
+ SearchCache.get().load()
+ .firstOrNull { it.query.equals(q, ignoreCase = true) }
+ ?.items
+ } else null
+ if (cached != null && cached.isNotEmpty()) {
+ _ui.value = _ui.value.copy(
+ loading = true,
+ error = null,
+ results = cached,
+ fromCache = true,
+ )
+ } else {
+ _ui.value = _ui.value.copy(
+ loading = true,
+ error = null,
+ results = emptyList(),
+ fromCache = false,
+ )
+ }
+
viewModelScope.launch {
try {
// strawcore.search() is suspend on the tokio runtime baked
@@ -63,11 +115,22 @@ class SearchViewModel : ViewModel() {
uploadDateRelative = r.uploadDateRelative,
)
}
- _ui.value = _ui.value.copy(loading = false, results = items)
+ _ui.value = _ui.value.copy(
+ loading = false,
+ results = items,
+ fromCache = false,
+ )
// Record AFTER the search succeeds so mistyped queries
// that error out don't pollute the recent-searches list.
runCatching { History.get().recordSearch(q) }
+ if (Settings.get().cacheEnabled.value) {
+ withContext(Dispatchers.IO) {
+ runCatching { SearchCache.get().record(q, items) }
+ }
+ }
} catch (t: Throwable) {
+ // Keep the cached preview visible on network failure so
+ // the user still has something to look at while offline.
_ui.value = _ui.value.copy(
loading = false,
error = t.message ?: t.javaClass.simpleName,
@@ -75,4 +138,27 @@ 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.
+ */
+ private fun reactiveFilter(q: String): List {
+ val needle = q.lowercase()
+ val pool = buildList {
+ SearchCache.get().load().forEach { addAll(it.items) }
+ FeedCache.get().load().values.forEach { addAll(it.items) }
+ }
+ return pool.asSequence()
+ .filter { item ->
+ item.title.lowercase().contains(needle)
+ || item.uploader.lowercase().contains(needle)
+ }
+ .distinctBy { it.url }
+ .take(60)
+ .toList()
+ }
}
diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt
index f3e704b0d..1f8339ca8 100644
--- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt
@@ -40,12 +40,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
+import android.widget.Toast
+import com.sulkta.straw.data.FeedCache
import com.sulkta.straw.data.History
import com.sulkta.straw.data.MaxResolution
import com.sulkta.straw.data.SbCategory
+import com.sulkta.straw.data.SearchCache
import com.sulkta.straw.data.Settings
+import com.sulkta.straw.data.ThemeMode
import com.sulkta.straw.feature.dataimport.ImportResult
import com.sulkta.straw.feature.dataimport.SettingsImport
+import com.sulkta.straw.util.LogDump
import kotlinx.coroutines.launch
@Composable
@@ -134,6 +139,81 @@ fun SettingsScreen() {
HorizontalDivider()
}
+ Spacer(modifier = Modifier.height(32.dp))
+ Text(
+ "Appearance",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ "Light, dark, or follow the system setting.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ val theme by store.themeMode.collectAsState()
+ ThemeMode.entries.forEach { t ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { store.setThemeMode(t) }
+ .padding(vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = if (t == theme) "• ${t.label}" else " ${t.label}",
+ style = MaterialTheme.typography.bodyLarge,
+ color = if (t == theme) MaterialTheme.colorScheme.primary
+ else MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ HorizontalDivider()
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+ Text(
+ "Local cache",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ "Caches the subs feed and recent searches on disk so the app " +
+ "paints instantly on cold start and you can search " +
+ "previously-seen videos with no network. ~400 KB max. " +
+ "Turn it off to save space on low-storage devices.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ val cacheEnabled by store.cacheEnabled.collectAsState()
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ "Enable local cache",
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Switch(
+ checked = cacheEnabled,
+ onCheckedChange = { checked ->
+ store.setCacheEnabled(checked)
+ if (!checked) {
+ // Wipe on disable — leaving stale bytes around
+ // defeats the purpose of opting out.
+ FeedCache.get().clear()
+ SearchCache.get().clear()
+ }
+ },
+ )
+ }
+
Spacer(modifier = Modifier.height(32.dp))
Text(
"History",
@@ -150,6 +230,36 @@ fun SettingsScreen() {
}
}
+ Spacer(modifier = Modifier.height(32.dp))
+ Text(
+ "Diagnostics",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ "Dump this app's recent logcat to a text file and open the " +
+ "system share sheet — attach it when reporting an issue.",
+ style = MaterialTheme.typography.bodySmall,
+ 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…")
+ }
+
Spacer(modifier = Modifier.height(32.dp))
Text(
"Import from NewPipe / Tubular",
diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt b/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt
new file mode 100644
index 000000000..9b4675aeb
--- /dev/null
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt
@@ -0,0 +1,76 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Sulkta-Coop
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * Capture this process's logcat into a file and return a share Intent.
+ * 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.
+ */
+
+package com.sulkta.straw.util
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Process
+import androidx.core.content.FileProvider
+import java.io.File
+import java.io.IOException
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+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.
+ */
+ fun capture(context: Context): Result = runCatching {
+ val pid = Process.myPid()
+ val timestamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
+ val outFile = File(context.cacheDir, "straw-logs-$timestamp.txt")
+
+ // -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?)")
+ }
+
+ // 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)
+ }
+ }
+}
diff --git a/strawApp/src/main/res/xml/file_paths.xml b/strawApp/src/main/res/xml/file_paths.xml
new file mode 100644
index 000000000..92e012475
--- /dev/null
+++ b/strawApp/src/main/res/xml/file_paths.xml
@@ -0,0 +1,8 @@
+
+
+
+
+