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:
parent
c74b06436f
commit
a776fbf2e4
12 changed files with 443 additions and 12 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 = 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"
|
||||
|
|
|
|||
|
|
@ -60,5 +60,16 @@
|
|||
<action android:name="androidx.media3.session.MediaSessionService" />
|
||||
</intent-filter>
|
||||
</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>
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
}
|
||||
|
|
@ -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> = _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) {
|
||||
// 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<SbCategory> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<StreamItem> = 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<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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
76
strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt
Normal file
76
strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
8
strawApp/src/main/res/xml/file_paths.xml
Normal file
8
strawApp/src/main/res/xml/file_paths.xml
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue