refactor: take the last nine DataStores off the Context delegate

Every other store in the app now opens through AppPreferenceStores; these
nine were still on androidx's `Context.preferencesDataStore`, which is the
pattern the rest of this branch removed. They are the stores behind the
napplet sandbox, the in-app browser, favorites, chess dismissals and the
two calendar-reminder files.

The names are unchanged, and that is the whole safety argument. The holder
reproduces `filesDir/datastore/<name>.preferences_pb`, which is exactly
what the delegate resolved to, so every one of these opens the file it was
already using: nothing migrates, and a rollback finds its data where it
left it. A typo in one of these strings would silently orphan that store's
data rather than fail, so they were copied across verbatim.

The calendar migrations moved with their files. DataStore runs a file's
migrations once, when that file is first opened, so they have to be
attached to the file by the holder rather than by whichever caller happens
to open it first — the same reason shared_settings' migration lives there.

Two shapes changed rather than being mechanically translated:

- The registries' `Context` receiver is gone. These stores are app-scoped
  now, so a receiver the body ignores would claim a dependency that is not
  real. The `appContext ?: return` guards stay: they mean "init() has run",
  which is still true and still worth keeping.
- DataStoreNappletStorage and DataStoreNappletPermissionStore lose their
  Context secondary constructors instead of keeping an unused parameter.
  Both call sites pass the store directly.

On the process boundary, since Amethyst.instance is deliberately unset in
the `:napplet` sandbox and reaching it there throws: all four components
declared `android:process=":napplet"` are napplethost.* classes from the
:nappletHost module, NappletBrokerService declares no process and so runs
in main, and onCreate's sandbox early-return precedes the one
WebAppNetworkRegistry.init() call. All nine stores are main-process only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L
This commit is contained in:
Claude
2026-09-24 22:42:22 +00:00
parent ea459bc51e
commit 2539e51035
13 changed files with 124 additions and 81 deletions
@@ -79,8 +79,12 @@ import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilde
import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector
import com.vitorpamplona.amethyst.model.torState.TorRelayState
import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore
import com.vitorpamplona.amethyst.service.calendar.CALENDAR_REMINDER_LOG_STORE
import com.vitorpamplona.amethyst.service.calendar.CALENDAR_REMINDER_SETTINGS_STORE
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
import com.vitorpamplona.amethyst.service.calendar.calendarReminderLogMigrations
import com.vitorpamplona.amethyst.service.calendar.calendarReminderSettings
import com.vitorpamplona.amethyst.service.calendar.calendarReminderSettingsMigrations
import com.vitorpamplona.amethyst.service.cast.CastRegistry
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
import com.vitorpamplona.amethyst.service.crashreports.CrashReportCache
@@ -257,6 +261,8 @@ class AppModules(
// One file per account, so the migration is per name rather than a constant.
name.startsWith(CashuPreferences.FILE_PREFIX) ->
listOf(CashuPreferences.legacyMigration(appContext, name.removePrefix(CashuPreferences.FILE_PREFIX)))
name == CALENDAR_REMINDER_SETTINGS_STORE -> calendarReminderSettingsMigrations(appContext)
name == CALENDAR_REMINDER_LOG_STORE -> calendarReminderLogMigrations(appContext)
else -> emptyList()
}
},
@@ -855,8 +861,10 @@ class AppModules(
*/
val nappletAccountScope: () -> String = { sessionManager.loggedInAccount()?.pubKey ?: "" }
// Singleton stores for napplet permissions — DataStore v1 enforces one instance per file.
val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appContext, nappletAccountScope) }
// Singleton stores for napplet permissions. The holder is what enforces
// DataStore's one-instance-per-file rule now; this stays a lazy val so the
// ledger below and the broker share one object.
val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appStores.getDataStore("napplet_permissions"), nappletAccountScope) }
/**
* The one napplet permission ledger for the main process. Its persistent half is just the store
@@ -1347,7 +1355,7 @@ class AppModules(
Filter(kinds = listOf(CalendarDateSlotEvent.KIND, CalendarTimeSlotEvent.KIND)),
).conflate()
.collect {
if (appContext.calendarReminderSettings().load().enabled &&
if (calendarReminderSettings().load().enabled &&
CalendarReminderWorker.couldStillFire(CalendarReminderWorker.acceptedRsvpsInCache(), TimeUtils.now())
) {
CalendarReminderWorker.schedule(appContext)
@@ -21,9 +21,11 @@
package com.vitorpamplona.amethyst.favorites
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.utils.Log
@@ -37,7 +39,15 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
private val Context.browserHistoryDataStore by preferencesDataStore(name = "browser_history")
/**
* The browser-history file, on the app-wide holder rather than a `Context` delegate.
* Same path the delegate resolved to, so nothing migrates.
*
* Main process only: [Amethyst.instance] is deliberately unset in the
* `:napplet` sandbox.
*/
private val browserHistoryDataStore: DataStore<Preferences>
get() = Amethyst.instance.appStores.getDataStore("browser_history")
/**
* One device-local visited site, keyed by full [url]. [visitCount]/[lastVisitedAt] drive frecency ranking
@@ -83,7 +93,7 @@ object BrowserHistoryRegistry {
val ctx = context.applicationContext
appContext = ctx
scope.launch {
val json = ctx.browserHistoryDataStore.data.first()[KEY]
val json = browserHistoryDataStore.data.first()[KEY]
val loaded = if (json != null) decode(json) else emptyList()
// Merge disk under anything already recorded this session (session wins, newest-first).
update { current -> dedupeNewestFirst(current + loaded) }
@@ -136,9 +146,9 @@ object BrowserHistoryRegistry {
}
private fun persist(json: String) {
val ctx = appContext ?: return
appContext ?: return
scope.launch {
ctx.browserHistoryDataStore.edit { it[KEY] = json }
browserHistoryDataStore.edit { it[KEY] = json }
}
}
@@ -21,9 +21,11 @@
package com.vitorpamplona.amethyst.favorites
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.utils.Log
@@ -38,7 +40,15 @@ import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import java.util.concurrent.ConcurrentHashMap
private val Context.favoriteAppsDataStore by preferencesDataStore(name = "favorite_apps")
/**
* The favorite-apps file, on the app-wide holder rather than a `Context` delegate.
* Same path the delegate resolved to, so nothing migrates.
*
* Main process only: [Amethyst.instance] is deliberately unset in the
* `:napplet` sandbox.
*/
private val favoriteAppsDataStore: DataStore<Preferences>
get() = Amethyst.instance.appStores.getDataStore("favorite_apps")
/**
* The user's device-local list of [FavoriteApp]s — the single source of truth shared by the bottom
@@ -81,7 +91,7 @@ object FavoriteAppsRegistry {
val ctx = context.applicationContext
appContext = ctx
scope.launch {
val prefs = ctx.favoriteAppsDataStore.data.first()
val prefs = favoriteAppsDataStore.data.first()
val loaded = prefs[KEY]?.let { decode(it) } ?: emptyList()
// Don't clobber adds made in this session before hydration finished, and don't resurrect
// anything the user removed in that same window.
@@ -139,16 +149,16 @@ object FavoriteAppsRegistry {
}
private fun persist(json: String) {
val ctx = appContext ?: return
appContext ?: return
scope.launch {
ctx.favoriteAppsDataStore.edit { it[KEY] = json }
favoriteAppsDataStore.edit { it[KEY] = json }
}
}
private fun persistManifests(json: String) {
val ctx = appContext ?: return
appContext ?: return
scope.launch {
ctx.favoriteAppsDataStore.edit { it[MANIFESTS_KEY] = json }
favoriteAppsDataStore.edit { it[MANIFESTS_KEY] = json }
}
}
@@ -20,19 +20,15 @@
*/
package com.vitorpamplona.amethyst.napplet
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionStore
import kotlinx.coroutines.flow.first
private val Context.nappletPermissionsDataStore by preferencesDataStore(name = "napplet_permissions")
/**
* Persists the standing napplet grants ([GrantState.ALLOW_ALWAYS] / [GrantState.DENY]) in a
* dedicated DataStore. Keyed by `"<coordinate>\u0000<capability>"` so a coordinate's grants can
@@ -43,9 +39,6 @@ class DataStoreNappletPermissionStore(
private val dataStore: DataStore<Preferences>,
private val accountPubKey: () -> String,
) : NappletPermissionStore {
constructor(context: Context, accountPubKey: () -> String) :
this(context.applicationContext.nappletPermissionsDataStore, accountPubKey)
/**
* Grants belong to one account. [accountPubKey] is read at call time, so an account switch moves
* every read and write to that account's namespace with no rebuild — a grant made by one account
@@ -20,17 +20,13 @@
*/
package com.vitorpamplona.amethyst.napplet
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.vitorpamplona.amethyst.commons.napplet.NappletStorage
import kotlinx.coroutines.flow.first
private val Context.nappletStorageDataStore by preferencesDataStore(name = "napplet_storage")
/**
* DataStore-backed [NappletStorage]. Every key is prefixed with the **active account** and then the
* applet's coordinate, so one napplet's keys can never collide with another's, one account's data is
@@ -44,9 +40,6 @@ class DataStoreNappletStorage(
private val dataStore: DataStore<Preferences>,
private val accountPubKey: () -> String,
) : NappletStorage {
constructor(context: Context, accountPubKey: () -> String) :
this(context.applicationContext.nappletStorageDataStore, accountPubKey)
override suspend fun get(
coordinate: String,
key: String,
@@ -90,7 +90,7 @@ class NappletBrokerService : Service() {
private val signerLedger by lazy { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) }
// Per-applet sandboxed key-value store (namespaced by account + coordinate inside the impl).
private val storage by lazy { DataStoreNappletStorage(applicationContext, Amethyst.instance.nappletAccountScope) }
private val storage by lazy { DataStoreNappletStorage(Amethyst.instance.appStores.getDataStore("napplet_storage"), Amethyst.instance.nappletAccountScope) }
private val incoming by lazy { Messenger(Handler(Looper.getMainLooper(), ::handleMessage)) }
@@ -21,9 +21,11 @@
package com.vitorpamplona.amethyst.napplet
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.vitorpamplona.amethyst.Amethyst
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -32,7 +34,15 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
private val Context.nappletNetworkDataStore by preferencesDataStore(name = "napplet_network")
/**
* The per-napplet routing file, on the app-wide holder rather than a `Context` delegate.
* Same path the delegate resolved to, so nothing migrates.
*
* Main process only: [Amethyst.instance] is deliberately unset in the
* `:napplet` sandbox.
*/
private val nappletNetworkDataStore: DataStore<Preferences>
get() = Amethyst.instance.appStores.getDataStore("napplet_network")
/**
* Per-nSite network-routing preference: whether a site's traffic goes through **Tor** (the default)
@@ -69,7 +79,7 @@ object NappletNetworkRegistry {
appContext = ctx
hydration =
scope.launch {
ctx.nappletNetworkDataStore.data.first().asMap().forEach { (key, value) ->
nappletNetworkDataStore.data.first().asMap().forEach { (key, value) ->
// putIfAbsent: never clobber a choice made in this session before hydration finished.
modes.putIfAbsent(key.name, value != OPEN_WEB)
}
@@ -95,9 +105,9 @@ object NappletNetworkRegistry {
useTor: Boolean,
) {
modes[coordinate] = useTor
val ctx = appContext ?: return
appContext ?: return
scope.launch {
ctx.nappletNetworkDataStore.edit { it[stringPreferencesKey(coordinate)] = if (useTor) TOR else OPEN_WEB }
nappletNetworkDataStore.edit { it[stringPreferencesKey(coordinate)] = if (useTor) TOR else OPEN_WEB }
}
}
}
@@ -22,9 +22,11 @@ package com.vitorpamplona.amethyst.napplet
import android.content.Context
import androidx.core.net.toUri
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.vitorpamplona.amethyst.Amethyst
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -33,7 +35,16 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
private val Context.webUrlNetworkDataStore by preferencesDataStore(name = "weburl_network")
/**
* The per-site routing file, on the app-wide holder rather than a `Context`
* delegate. Same path the delegate resolved to, so nothing migrates.
*
* Main process only: [Amethyst.instance] is deliberately unset in the
* `:napplet` sandbox, and this registry is only touched from the browser
* chrome that runs in the main process.
*/
private val webUrlNetworkDataStore: DataStore<Preferences>
get() = Amethyst.instance.appStores.getDataStore("weburl_network")
/**
* Per-web-client network-routing preference: whether a favorited URL / browsed site routes through
@@ -67,7 +78,7 @@ object WebAppNetworkRegistry {
appContext = ctx
hydration =
scope.launch {
ctx.webUrlNetworkDataStore.data.first().asMap().forEach { (key, value) ->
webUrlNetworkDataStore.data.first().asMap().forEach { (key, value) ->
// putIfAbsent: never clobber a choice made in this session before hydration finished.
modes.putIfAbsent(key.name, value != OPEN_WEB)
}
@@ -98,9 +109,9 @@ object WebAppNetworkRegistry {
) {
val host = hostKeyOf(url)
modes[host] = useTor
val ctx = appContext ?: return
appContext ?: return
scope.launch {
ctx.webUrlNetworkDataStore.edit { it[stringPreferencesKey(host)] = if (useTor) TOR else OPEN_WEB }
webUrlNetworkDataStore.edit { it[stringPreferencesKey(host)] = if (useTor) TOR else OPEN_WEB }
}
}
}
@@ -21,9 +21,11 @@
package com.vitorpamplona.amethyst.service.calendar
import android.content.Context
import androidx.datastore.core.DataMigration
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.preferences.CalendarReminderLogStore
import com.vitorpamplona.amethyst.commons.model.preferences.CalendarReminderSettings
import com.vitorpamplona.amethyst.commons.model.preferences.CalendarReminderSettingsStore
@@ -34,43 +36,47 @@ import com.vitorpamplona.amethyst.commons.model.preferences.CopyOnceMigration
*
* The store classes live in commons; only the file location and the one-off
* lift out of the legacy SharedPreferences are Android's business.
*
* Both files sit on `AppPreferenceStores` rather than a `Context` delegate.
* The names below are the delegate's names, and the holder reproduces the path
* it resolved to, so nothing migrates. The migrations move with them: DataStore
* runs a file's migrations once, when that file is first opened, so they have
* to be attached to the file by the holder rather than by whoever opens it.
*/
private const val LEGACY_SETTINGS_FILE = "amethyst_calendar_reminder_prefs"
private const val LEGACY_LOG_FILE = "amethyst_calendar_reminders"
private val Context.calendarReminderSettingsData by preferencesDataStore(
name = "calendar_reminder_settings",
produceMigrations = { context ->
listOf(
CopyOnceMigration("migrated.calendarReminderSettings") { out ->
val legacy = context.getSharedPreferences(LEGACY_SETTINGS_FILE, Context.MODE_PRIVATE)
if (legacy.contains("enabled")) {
out[booleanPreferencesKey("enabled")] = legacy.getBoolean("enabled", CalendarReminderSettings.DEFAULT_ENABLED)
}
if (legacy.contains("lead_minutes")) {
out[intPreferencesKey("lead_minutes")] = legacy.getInt("lead_minutes", CalendarReminderSettings.DEFAULT_LEAD_MINUTES)
}
},
)
},
)
/** Store (and file) names, as [Amethyst.appStores] keys them. */
const val CALENDAR_REMINDER_SETTINGS_STORE = "calendar_reminder_settings"
const val CALENDAR_REMINDER_LOG_STORE = "calendar_reminder_log"
private val Context.calendarReminderLogData by preferencesDataStore(
name = "calendar_reminder_log",
produceMigrations = { context ->
listOf(
CopyOnceMigration("migrated.calendarReminderLog") { out ->
val legacy = context.getSharedPreferences(LEGACY_LOG_FILE, Context.MODE_PRIVATE)
// Values are the event-start times the reminders fired for; anything
// else in the file is not ours and is left behind.
legacy.all.forEach { (key, value) ->
if (value is Long) out[CalendarReminderLogStore.keyFor(key.removePrefix("notified:"))] = value
}
},
)
},
)
/** The one-off copy of the reminder settings out of the legacy prefs file. */
fun calendarReminderSettingsMigrations(context: Context): List<DataMigration<Preferences>> =
listOf(
CopyOnceMigration("migrated.calendarReminderSettings") { out ->
val legacy = context.getSharedPreferences(LEGACY_SETTINGS_FILE, Context.MODE_PRIVATE)
if (legacy.contains("enabled")) {
out[booleanPreferencesKey("enabled")] = legacy.getBoolean("enabled", CalendarReminderSettings.DEFAULT_ENABLED)
}
if (legacy.contains("lead_minutes")) {
out[intPreferencesKey("lead_minutes")] = legacy.getInt("lead_minutes", CalendarReminderSettings.DEFAULT_LEAD_MINUTES)
}
},
)
fun Context.calendarReminderSettings() = CalendarReminderSettingsStore(calendarReminderSettingsData)
/** The one-off copy of the fired-reminder log out of the legacy prefs file. */
fun calendarReminderLogMigrations(context: Context): List<DataMigration<Preferences>> =
listOf(
CopyOnceMigration("migrated.calendarReminderLog") { out ->
val legacy = context.getSharedPreferences(LEGACY_LOG_FILE, Context.MODE_PRIVATE)
// Values are the event-start times the reminders fired for; anything
// else in the file is not ours and is left behind.
legacy.all.forEach { (key, value) ->
if (value is Long) out[CalendarReminderLogStore.keyFor(key.removePrefix("notified:"))] = value
}
},
)
fun Context.calendarReminderLog() = CalendarReminderLogStore(calendarReminderLogData)
fun calendarReminderSettings() = CalendarReminderSettingsStore(Amethyst.instance.appStores.getDataStore(CALENDAR_REMINDER_SETTINGS_STORE))
fun calendarReminderLog() = CalendarReminderLogStore(Amethyst.instance.appStores.getDataStore(CALENDAR_REMINDER_LOG_STORE))
@@ -65,7 +65,7 @@ class CalendarReminderWorker(
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
runCatching { Amethyst.instance.resourceUsage.add(UsageKeys.workerRuns("calendarReminder"), 1) }
val settings = applicationContext.calendarReminderSettings().load()
val settings = calendarReminderSettings().load()
if (!settings.enabled) {
Log.d(TAG) { "Reminders disabled; ending periodic chain." }
// The settings toggle re-schedules on enable; no reason to keep
@@ -75,7 +75,7 @@ class CalendarReminderWorker(
}
val now = TimeUtils.now()
val windowEnd = now + settings.leadMinutes * 60L
val store = applicationContext.calendarReminderLog()
val store = calendarReminderLog()
// Walk every kind-31925 RSVP authored by an account on this device. We don't have a
// multi-account "all logged-in pubkeys" view here, so we accept any RSVP that's
@@ -68,7 +68,7 @@ import kotlinx.coroutines.launch
fun CalendarReminderSettingsScreen(nav: INav) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val store = remember(context) { context.calendarReminderSettings() }
val store = remember { calendarReminderSettings() }
// DataStore reads are suspend, so the first frame renders the defaults and
// the stored values arrive right after. Collecting the flow rather than
@@ -20,8 +20,9 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess
import android.content.Context
import androidx.datastore.preferences.preferencesDataStore
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import com.vitorpamplona.amethyst.Amethyst
/**
* Where Android keeps the dismissed-chess-games store.
@@ -31,4 +32,5 @@ import androidx.datastore.preferences.preferencesDataStore
* data over is not worth the code. Anyone who had dismissed a game sees it once
* more and dismisses it again.
*/
internal val Context.chessDismissedGamesData by preferencesDataStore(name = "chess_dismissed_games_v2")
internal val chessDismissedGamesData: DataStore<Preferences>
get() = Amethyst.instance.appStores.getDataStore("chess_dismissed_games_v2")
@@ -61,7 +61,7 @@ class ChessViewModelNew(
private val publisher = AndroidChessPublisher(account)
private val fetcher = AndroidRelayFetcher(account)
private val metadataProvider = AndroidMetadataProvider()
private val dismissedStorage = ChessDismissedGamesStore(application.chessDismissedGamesData)
private val dismissedStorage = ChessDismissedGamesStore(chessDismissedGamesData)
// Shared business logic (creates its own ChessLobbyState internally)
private val logic =