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