vc=34: settings batch — theme, cache toggle, log dump, reactive search

Five things land together — same surface, all the data/settings flow:

SettingsStore additions
  themeMode: System / Light / Dark (default System)
  cacheEnabled: Bool (default true). Single toggle gates both the
    subs feed cache (FeedCacheStore) and the new search cache.

Theme override
  StrawActivity reads Settings.themeMode and bypasses
  isSystemInDarkTheme when Light or Dark is chosen. Changes apply
  immediately via StateFlow recomposition — no restart needed.

SearchCacheStore (new)
  SharedPreferences JSON of the last 30 searches with 20 items each
  (~150 KB cap). Serialized via @Serializable StreamItem (already
  serializable since vc=33).

Reactive search
  SearchViewModel.onQueryChange now scans the merged corpus (saved
  searches + subs feed cache, ~1500 records max) as the user types
  past 2 chars. Matches on title or uploader (case-insensitive),
  dedup by URL, cap 60. UI shows a "Cached results · …" hint when
  the visible list is from cache so users know it's not the network
  result yet.
  submit() now paints any matching cached query immediately, then
  kicks the network. Network results overwrite cache on success and
  the cached preview survives network failures so offline users
  still see something.

Cache opt-out
  Settings switch "Enable local cache" wipes both stores when
  flipped off. FeedCacheVM + SearchVM both short-circuit their
  read/write paths when the flag is false.

Log dump
  util/LogDump captures this PID's logcat (-d -v threadtime --pid=)
  to cacheDir, returns a chooser Intent via FileProvider. New
  Settings → "Export logs…" button. Toast surfaces the failure
  reason if the dump command itself fails (sandbox-restricted
  devices etc.).
  FileProvider declared in manifest with cache-path "logs"; XML at
  res/xml/file_paths.xml. Authority = ${applicationId}.fileprovider.
This commit is contained in:
Kayos 2026-05-25 13:01:41 -07:00
parent c74b06436f
commit a776fbf2e4
12 changed files with 443 additions and 12 deletions

View file

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

View file

@ -60,5 +60,16 @@
<action android:name="androidx.media3.session.MediaSessionService" /> <action android:name="androidx.media3.session.MediaSessionService" />
</intent-filter> </intent-filter>
</service> </service>
<!-- FileProvider for sharing log dumps from Settings → Export logs. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application> </application>
</manifest> </manifest>

View file

@ -25,6 +25,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.media3.common.util.UnstableApi 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.channel.ChannelScreen
import com.sulkta.straw.feature.detail.VideoDetailScreen import com.sulkta.straw.feature.detail.VideoDetailScreen
import com.sulkta.straw.feature.download.DownloadsScreen import com.sulkta.straw.feature.download.DownloadsScreen
@ -67,7 +69,16 @@ class StrawActivity : ComponentActivity() {
val startUrl = pickYouTubeUrl(intent) val startUrl = pickYouTubeUrl(intent)
setContent { 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 // One MediaController for the whole activity. Every screen pulls
// it via LocalStrawController; the minibar overlay below uses it // it via LocalStrawController; the minibar overlay below uses it
// too. Single player, single source of truth. // too. Single player, single source of truth.

View file

@ -9,6 +9,7 @@ import android.app.Application
import com.sulkta.straw.data.FeedCache import com.sulkta.straw.data.FeedCache
import com.sulkta.straw.data.History import com.sulkta.straw.data.History
import com.sulkta.straw.data.Playlists import com.sulkta.straw.data.Playlists
import com.sulkta.straw.data.SearchCache
import com.sulkta.straw.data.Settings import com.sulkta.straw.data.Settings
import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.data.Subscriptions
@ -25,5 +26,6 @@ class StrawApp : Application() {
Subscriptions.init(this) Subscriptions.init(this)
Playlists.init(this) Playlists.init(this)
FeedCache.init(this) FeedCache.init(this)
SearchCache.init(this)
} }
} }

View file

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

View file

@ -37,9 +37,17 @@ enum class MaxResolution(val label: String, val ceiling: Int) {
P144("144p", 144), 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 PREFS = "straw_settings"
private const val KEY_SB_CATS = "sb_categories_v1" private const val KEY_SB_CATS = "sb_categories_v1"
private const val KEY_MAX_RES = "max_resolution_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) { class SettingsStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@ -50,6 +58,12 @@ class SettingsStore(context: Context) {
private val _maxResolution = MutableStateFlow(loadMaxResolution()) private val _maxResolution = MutableStateFlow(loadMaxResolution())
val maxResolution: StateFlow<MaxResolution> = _maxResolution.asStateFlow() val maxResolution: StateFlow<MaxResolution> = _maxResolution.asStateFlow()
private val _themeMode = MutableStateFlow(loadThemeMode())
val themeMode: StateFlow<ThemeMode> = _themeMode.asStateFlow()
private val _cacheEnabled = MutableStateFlow(sp.getBoolean(KEY_CACHE_ENABLED, true))
val cacheEnabled: StateFlow<Boolean> = _cacheEnabled.asStateFlow()
fun toggle(cat: SbCategory) { fun toggle(cat: SbCategory) {
// Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore. // Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore.
val next = _sbCategories.updateAndGet { cur -> val next = _sbCategories.updateAndGet { cur ->
@ -63,6 +77,16 @@ class SettingsStore(context: Context) {
sp.edit().putString(KEY_MAX_RES, r.name).apply() 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<SbCategory> { private fun loadCategories(): Set<SbCategory> {
val raw = sp.getStringSet(KEY_SB_CATS, null) val raw = sp.getStringSet(KEY_SB_CATS, null)
return if (raw == null) { return if (raw == null) {
@ -77,6 +101,11 @@ class SettingsStore(context: Context) {
val name = sp.getString(KEY_MAX_RES, null) ?: return MaxResolution.Auto val name = sp.getString(KEY_MAX_RES, null) ?: return MaxResolution.Auto
return MaxResolution.entries.firstOrNull { it.name == name } ?: 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 { object Settings {

View file

@ -21,6 +21,7 @@ import androidx.lifecycle.viewModelScope
import com.sulkta.straw.data.ChannelRef import com.sulkta.straw.data.ChannelRef
import com.sulkta.straw.data.FeedCache import com.sulkta.straw.data.FeedCache
import com.sulkta.straw.data.FeedCacheEntry import com.sulkta.straw.data.FeedCacheEntry
import com.sulkta.straw.data.Settings
import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.data.Subscriptions
import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.strawLogW import com.sulkta.straw.util.strawLogW
@ -69,7 +70,9 @@ class SubscriptionFeedViewModel : ViewModel() {
// round-trip. The refresh that follows replaces stale entries // round-trip. The refresh that follows replaces stale entries
// in-place — items animate to their new positions via LazyColumn // in-place — items animate to their new positions via LazyColumn
// key stability (URLs are stable across fetches). // 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()) { if (saved.isNotEmpty()) {
channelCache.putAll(saved) channelCache.putAll(saved)
val channels = Subscriptions.get().subs.value val channels = Subscriptions.get().subs.value
@ -149,8 +152,11 @@ class SubscriptionFeedViewModel : ViewModel() {
// Persist what we just freshened. Off the main thread — // Persist what we just freshened. Off the main thread —
// JSON encode on 30 subs * 30 items is small but not // JSON encode on 30 subs * 30 items is small but not
// free, and SharedPreferences.apply is async anyway. // free, and SharedPreferences.apply is async anyway.
withContext(Dispatchers.IO) { // Skipped entirely when the user has disabled caching.
runCatching { FeedCache.get().save(channelCache.toMap()) } if (Settings.get().cacheEnabled.value) {
withContext(Dispatchers.IO) {
runCatching { FeedCache.get().save(channelCache.toMap()) }
}
} }
} catch (t: Throwable) { } catch (t: Throwable) {
_ui.update { _ui.update {

View file

@ -116,10 +116,21 @@ fun SearchScreen(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { Text("hit enter to search") } ) { Text("hit enter to search") }
else -> LazyColumn(modifier = Modifier.fillMaxSize()) { else -> Column(modifier = Modifier.fillMaxSize()) {
items(state.results) { item -> if (state.fromCache) {
ResultRow(item = item) { onOpenVideo(item.url, item.title) } Text(
HorizontalDivider() 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()
}
} }
} }
} }

View file

@ -7,17 +7,28 @@ package com.sulkta.straw.feature.search
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.sulkta.straw.data.FeedCache
import com.sulkta.straw.data.History 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class SearchUiState( data class SearchUiState(
val query: String = "", val query: String = "",
val results: List<StreamItem> = emptyList(), val results: List<StreamItem> = emptyList(),
val loading: Boolean = false, val loading: Boolean = false,
val error: String? = null, 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 @kotlinx.serialization.Serializable
@ -40,12 +51,53 @@ class SearchViewModel : ViewModel() {
fun onQueryChange(q: String) { fun onQueryChange(q: String) {
_ui.value = _ui.value.copy(query = q) _ui.value = _ui.value.copy(query = q)
// Reactive filter: scan every cached item (search-cache + subs
// 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() { fun submit() {
val q = _ui.value.query.trim() val q = _ui.value.query.trim()
if (q.isEmpty()) return 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 { viewModelScope.launch {
try { try {
// strawcore.search() is suspend on the tokio runtime baked // strawcore.search() is suspend on the tokio runtime baked
@ -63,11 +115,22 @@ class SearchViewModel : ViewModel() {
uploadDateRelative = r.uploadDateRelative, 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 // Record AFTER the search succeeds so mistyped queries
// that error out don't pollute the recent-searches list. // that error out don't pollute the recent-searches list.
runCatching { History.get().recordSearch(q) } runCatching { History.get().recordSearch(q) }
if (Settings.get().cacheEnabled.value) {
withContext(Dispatchers.IO) {
runCatching { SearchCache.get().record(q, items) }
}
}
} catch (t: Throwable) { } 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( _ui.value = _ui.value.copy(
loading = false, loading = false,
error = t.message ?: t.javaClass.simpleName, 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<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()
.filter { item ->
item.title.lowercase().contains(needle)
|| item.uploader.lowercase().contains(needle)
}
.distinctBy { it.url }
.take(60)
.toList()
}
} }

View file

@ -40,12 +40,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp 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.History
import com.sulkta.straw.data.MaxResolution import com.sulkta.straw.data.MaxResolution
import com.sulkta.straw.data.SbCategory import com.sulkta.straw.data.SbCategory
import com.sulkta.straw.data.SearchCache
import com.sulkta.straw.data.Settings 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.ImportResult
import com.sulkta.straw.feature.dataimport.SettingsImport import com.sulkta.straw.feature.dataimport.SettingsImport
import com.sulkta.straw.util.LogDump
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
@ -134,6 +139,81 @@ fun SettingsScreen() {
HorizontalDivider() 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)) Spacer(modifier = Modifier.height(32.dp))
Text( Text(
"History", "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)) Spacer(modifier = Modifier.height(32.dp))
Text( Text(
"Import from NewPipe / Tubular", "Import from NewPipe / Tubular",

View file

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

View file

@ -0,0 +1,8 @@
<?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. -->
<cache-path name="logs" path="." />
</paths>