mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 08:47:33 +00:00
fix(perf): keep favicon and settings disk reads off the main thread
Two StrictMode disk violations on startup and on IPC, fixed differently because they are different problems. **Favicon storage was writing on the IPC handler thread**, i.e. the main looper: `BrowserIconRegistry.record` did `File.writeBytes` inline, and `init` scanned the directory with `listFiles`. Both now run on an IO scope. The directory is still published synchronously so `iconModelFor` and `record` work immediately — only the scan and the write are deferred. `keys` updates after the bytes are actually on disk, so a reader is never told an icon exists before the file backing it does, and until the scan lands an icon renders its placeholder for a frame and then recomposes, which is what the StateFlow is for. **The settings read was left synchronous on purpose, and stays that way.** `notificationServiceEnabled` reads SharedPreferences inside a lazy initialiser; the surrounding comments record why it is not hydrated asynchronously — an async hydrate reopens a window where a late disk read clobbers a user's toggle. Converting it would have looked like a StrictMode cleanup while resurrecting a settings-corruption bug. Instead the prefs file is warmed on an IO thread at startup, so the first synchronous read hits SharedPreferences' in-memory cache. Best-effort by design: if a main-thread reader wins the race it pays the disk hit once, exactly as before, and correctness is unchanged either way. Verified on device: `notificationServiceEnabled` StrictMode hits went from 9 to 0 on a cold start, no icon storage failures, 884 ms cold start, no crashes — and both pinned web-app icons (ditto.pub and Brainstorm) still render, which is the thing making the write async could plausibly have broken. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
4255ba3345
commit
a040aa522c
@@ -32,6 +32,9 @@ import com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabHost
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.LogLevel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
@@ -95,6 +98,10 @@ class Amethyst : Application() {
|
||||
// Index device-local captured favicons (main process only; decorates favorites + suggestions).
|
||||
BrowserIconRegistry.init(this)
|
||||
|
||||
// Warm the global-settings prefs off-main so the first (deliberately synchronous) read of
|
||||
// them does not hit disk on the main thread. See LocalPreferences.warmGlobalSettings.
|
||||
CoroutineScope(Dispatchers.IO).launch { LocalPreferences.warmGlobalSettings() }
|
||||
|
||||
// Hydrate the per-web-client Tor routing preferences so a site opted out of Tor (some reject Tor
|
||||
// exits) starts on the open web without first flashing a failed Tor load.
|
||||
WebAppNetworkRegistry.init(this)
|
||||
|
||||
@@ -236,6 +236,19 @@ object LocalPreferences {
|
||||
// the source of truth, so there is no async hydrate that could clobber a user toggle.
|
||||
private fun globalSettingsPrefs(): SharedPreferences = Amethyst.instance.appContext.getSharedPreferences("amethyst_global_settings", Context.MODE_PRIVATE)
|
||||
|
||||
/**
|
||||
* Loads the global-settings prefs file into SharedPreferences' in-memory cache, off the main
|
||||
* thread, so the first synchronous read below hits memory rather than disk.
|
||||
*
|
||||
* The read itself is deliberately synchronous — see [setNotificationServiceEnabled]: an async
|
||||
* hydrate reintroduces a window where a late disk read clobbers a user's toggle. So this warms
|
||||
* the cache instead of deferring the read. Best-effort: if a main-thread reader wins the race it
|
||||
* simply pays the disk hit once, exactly as before.
|
||||
*/
|
||||
fun warmGlobalSettings() {
|
||||
globalSettingsPrefs().getBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, true)
|
||||
}
|
||||
|
||||
private val notificationServiceEnabled: MutableStateFlow<Boolean> by lazy {
|
||||
MutableStateFlow(globalSettingsPrefs().getBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, true))
|
||||
}
|
||||
|
||||
@@ -22,10 +22,14 @@ package com.vitorpamplona.amethyst.favorites
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
@@ -50,12 +54,28 @@ object BrowserIconRegistry {
|
||||
|
||||
@Volatile private var iconDir: File? = null
|
||||
|
||||
/** Binds the app context and indexes already-stored icons. Idempotent. */
|
||||
// Disk work runs here, never on the caller's thread. Both entry points are reached from threads
|
||||
// that must not block: init() from app startup and record() from the broker's IPC handler, which
|
||||
// is the main looper — StrictMode flagged the write, and a slow filesystem would have stalled the
|
||||
// UI while a favicon was saved.
|
||||
private val io = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
/**
|
||||
* Binds the app context and indexes already-stored icons. Idempotent.
|
||||
*
|
||||
* [iconDir] is published synchronously so [iconModelFor] and [record] work immediately; only the
|
||||
* directory scan is deferred. Until it lands [keys] is empty, so an icon simply renders its
|
||||
* placeholder for one frame and then recomposes — [keys] is a StateFlow precisely so that arrival
|
||||
* drives recomposition.
|
||||
*/
|
||||
fun init(context: Context) {
|
||||
if (iconDir != null) return
|
||||
val dir = File(context.applicationContext.filesDir, DIR).apply { mkdirs() }
|
||||
val dir = File(context.applicationContext.filesDir, DIR)
|
||||
iconDir = dir
|
||||
_keys.value = dir.listFiles()?.mapNotNull { it.name.removeSuffix(PNG).takeIf { n -> n.isNotBlank() } }?.toSet() ?: emptySet()
|
||||
io.launch {
|
||||
dir.mkdirs()
|
||||
_keys.value = dir.listFiles()?.mapNotNull { it.name.removeSuffix(PNG).takeIf { n -> n.isNotBlank() } }?.toSet() ?: emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
/** Persists [bytes] as the favicon for [host] and marks it available. Called from the broker on IPC. */
|
||||
@@ -66,11 +86,17 @@ object BrowserIconRegistry {
|
||||
val dir = iconDir ?: return
|
||||
if (host.isBlank() || bytes.isEmpty()) return
|
||||
val key = sanitize(host)
|
||||
try {
|
||||
File(dir, key + PNG).writeBytes(bytes)
|
||||
_keys.update { it + key }
|
||||
} catch (e: Exception) {
|
||||
Log.w("BrowserIconRegistry", "Failed to store favicon for $host", e)
|
||||
// Fire-and-forget: a favicon is a decoration, and the IPC handler must not wait on disk.
|
||||
// [keys] updates only after the bytes are actually on disk, so a reader can never be told an
|
||||
// icon exists before the file backing it does.
|
||||
io.launch {
|
||||
try {
|
||||
dir.mkdirs()
|
||||
File(dir, key + PNG).writeBytes(bytes)
|
||||
_keys.update { it + key }
|
||||
} catch (e: Exception) {
|
||||
Log.w("BrowserIconRegistry", "Failed to store favicon for $host", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user