mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 19:53:08 +00:00
feat: migrate the last legacy preference keys, table-driven
The seven keys still read only from `secret_keeper` now have a home in the new stores. `nostr_pubkey` is the one that mattered: without it `loadAccountConfigFromEncryptedStorage` returns null and the account disappears from the app with its private key sitting safe and unreachable in the key store. Six are per-account and go into a new AccountIdentityStore in the account's plain DataStore — pubkey, external-signer flag and package, local relay servers, open backup conflicts, and the key-backup flag. All of it is public, and the store file is already named after the npub, so nothing is revealed that the file name does not reveal. Unlike the earlier plain-store groups, the legacy writes stay: a rollback that loses a setting is an annoyance, one that loses the pubkey is an empty app. `has_backed_up_keys` is carried but deliberately kept out of the group's save. The nudge writes it on its own, so a group save would carry a value its caller never knew about and put the nudge back in front of everyone. The seventh, `shared_settings`, is global and had already been superseded by UiSharedPreferences' own DataStore — except nothing ever moved the old blob across, and the fallback only fires when the new store *throws*, not when it is merely empty. It now carries a guarded copy: the destination has to be unsaved (no `ui.theme`) before the old blob is written into it, so an install that has been using the new store keeps everything it has. `saveSharedSettings` went with it, having had no callers since that store landed. Migrations are now tables of (legacy name -> Preferences.Key) owned by each store, rather than blocks of hand-written copy lines. The point is not brevity: a block cannot be asked what it covers, so the check that gates deleting the legacy file would have to restate the list, and a key added to one and not the other is exactly the silent hole that makes deletion unsafe. The rename pairs are worth having in one place too — five of DialogDismissalStore's nine keys changed name on the way in, and `relay_auth_trust_my_relays_and_venues` lost its suffix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.LegacyPreferenceSource
|
||||
|
||||
/**
|
||||
* [LegacyPreferenceSource] over the `secret_keeper` files.
|
||||
*
|
||||
* Every getter reports absence as null rather than as a default, which
|
||||
* `SharedPreferences` itself cannot do — that distinction is what keeps a
|
||||
* migration from writing "false" over a key the user never set.
|
||||
*/
|
||||
class LegacySharedPreferences(
|
||||
private val prefs: SharedPreferences,
|
||||
) : LegacyPreferenceSource {
|
||||
override fun keys(): Set<String> = prefs.all.keys
|
||||
|
||||
override fun getBoolean(name: String): Boolean? = if (prefs.contains(name)) prefs.getBoolean(name, false) else null
|
||||
|
||||
override fun getString(name: String): String? = prefs.getString(name, null)
|
||||
|
||||
// SharedPreferences hands back the live set and documents that mutating it
|
||||
// corrupts the file, so this copies before anything downstream can hold it.
|
||||
override fun getStringSet(name: String): Set<String>? = prefs.getStringSet(name, null)?.toSet()
|
||||
}
|
||||
+74
-33
@@ -27,13 +27,16 @@ import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.datastore.core.DataMigration
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.CopyOnceMigration
|
||||
import com.vitorpamplona.amethyst.model.AccentColorType
|
||||
import com.vitorpamplona.amethyst.model.BooleanType
|
||||
import com.vitorpamplona.amethyst.model.ConnectivityType
|
||||
@@ -58,7 +61,11 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
val Context.sharedPreferencesDataStore: DataStore<Preferences> by preferencesDataStore(name = "shared_settings")
|
||||
/** The UI settings store. See [UiSharedPreferences.migrations] for the copy it carries. */
|
||||
val Context.sharedPreferencesDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = "shared_settings",
|
||||
produceMigrations = { UiSharedPreferences.migrations() },
|
||||
)
|
||||
|
||||
@Stable
|
||||
class UiSharedPreferences(
|
||||
@@ -275,43 +282,77 @@ class UiSharedPreferences(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes every UI setting into [preferences].
|
||||
*
|
||||
* Shared by [save] and by the one-shot copy out of the old
|
||||
* `shared_settings` blob, so the two cannot come to disagree about
|
||||
* which keys a complete set of UI settings has.
|
||||
*/
|
||||
internal fun MutablePreferences.write(sharedSettings: UiSettings) {
|
||||
val preferences = this
|
||||
preferences[UI_THEME] = sharedSettings.theme.name
|
||||
preferences[UI_LANGUAGE] = sharedSettings.preferredLanguage ?: ""
|
||||
preferences[UI_SHOW_IMAGES] = sharedSettings.automaticallyShowImages.name
|
||||
preferences[UI_START_PLAYBACK] = sharedSettings.automaticallyStartPlayback.name
|
||||
preferences[UI_PLAY_VIDEOS] = sharedSettings.automaticallyPlayVideos.name
|
||||
preferences[UI_SHOW_URL_PREVIEW] = sharedSettings.automaticallyShowUrlPreview.name
|
||||
preferences[UI_HIDE_NAVIGATION_BARS] = sharedSettings.automaticallyHideNavigationBars.name
|
||||
preferences[UI_SHOW_PROFILE_PICTURES] = sharedSettings.automaticallyShowProfilePictures.name
|
||||
preferences[UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR] = sharedSettings.dontShowPushNotificationSelector
|
||||
preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] = sharedSettings.dontAskForNotificationPermissions
|
||||
preferences[UI_FEATURE_SET] = sharedSettings.featureSet.name
|
||||
preferences[UI_GALLERY_SET] = sharedSettings.gallerySet.name
|
||||
preferences[UI_PROPOSE_AI_IMPROVEMENTS] = sharedSettings.automaticallyProposeAiImprovements.name
|
||||
preferences[UI_USE_TRACKED_BROADCASTS] = sharedSettings.useTrackedBroadcasts.name
|
||||
preferences[UI_AUTOMATICALLY_CREATE_DRAFTS] = sharedSettings.automaticallyCreateDrafts.name
|
||||
preferences[UI_SHOW_HOME_NEW_THREADS_TAB] = sharedSettings.showHomeNewThreadsTab
|
||||
preferences[UI_SHOW_HOME_CONVERSATIONS_TAB] = sharedSettings.showHomeConversationsTab
|
||||
preferences[UI_SHOW_HOME_EVERYTHING_TAB] = sharedSettings.showHomeEverythingTab
|
||||
preferences[UI_SHOW_PROFILE_BADGES] = sharedSettings.showProfileBadges
|
||||
preferences[UI_SHOW_PROFILE_APP_RECOMMENDATIONS] = sharedSettings.showProfileAppRecommendations
|
||||
preferences[UI_SHOW_PROFILE_ZAP_RECEIVED_FEED] = sharedSettings.showProfileZapReceivedFeed
|
||||
preferences[UI_SHOW_PROFILE_FOLLOWERS_FEED] = sharedSettings.showProfileFollowersFeed
|
||||
preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] = sharedSettings.dontShowOnchainPublicWarning
|
||||
preferences[UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT] = sharedSettings.suggestWorkoutsFromHealthConnect.name
|
||||
preferences[UI_ACCENT_COLOR] = sharedSettings.accentColor.name
|
||||
preferences[UI_FONT_FAMILY] = sharedSettings.fontFamily.name
|
||||
preferences[UI_FONT_SIZE] = sharedSettings.fontSize.name
|
||||
preferences[UI_COMPOSE_SIGNATURE] = sharedSettings.composeSignature
|
||||
preferences[UI_SHOW_ONCHAIN_WALLET] = sharedSettings.showOnchainWallet
|
||||
preferences[UI_SHOW_PAYTO_ZAP_CHIP] = sharedSettings.showPayToZapChip
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-shot copy out of the single `shared_settings` JSON blob these
|
||||
* settings used to be kept as, in the global encrypted file.
|
||||
*
|
||||
* Guarded, and it has to be. Unlike the per-account migrations, this
|
||||
* store has been the real home of these settings for a while, so most
|
||||
* installs already have a populated one — and copying an old blob over
|
||||
* it would undo every UI change the user has made since. [UI_THEME] is
|
||||
* the test: [save] writes every key unconditionally and is the only
|
||||
* writer, so its absence means this store has never been saved, which
|
||||
* is exactly the install whose settings are still only in the legacy
|
||||
* file.
|
||||
*/
|
||||
internal fun migrations(): List<DataMigration<Preferences>> =
|
||||
listOf(
|
||||
CopyOnceMigration("migrated.sharedSettings") { out ->
|
||||
if (out[UI_THEME] == null) {
|
||||
withContext(Dispatchers.IO) {
|
||||
LocalPreferences.loadSharedSettings()?.let { out.write(it) }
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
suspend fun save(
|
||||
sharedSettings: UiSettings,
|
||||
context: Context,
|
||||
) {
|
||||
try {
|
||||
context.sharedPreferencesDataStore.edit { preferences ->
|
||||
preferences[UI_THEME] = sharedSettings.theme.name
|
||||
preferences[UI_LANGUAGE] = sharedSettings.preferredLanguage ?: ""
|
||||
preferences[UI_SHOW_IMAGES] = sharedSettings.automaticallyShowImages.name
|
||||
preferences[UI_START_PLAYBACK] = sharedSettings.automaticallyStartPlayback.name
|
||||
preferences[UI_PLAY_VIDEOS] = sharedSettings.automaticallyPlayVideos.name
|
||||
preferences[UI_SHOW_URL_PREVIEW] = sharedSettings.automaticallyShowUrlPreview.name
|
||||
preferences[UI_HIDE_NAVIGATION_BARS] = sharedSettings.automaticallyHideNavigationBars.name
|
||||
preferences[UI_SHOW_PROFILE_PICTURES] = sharedSettings.automaticallyShowProfilePictures.name
|
||||
preferences[UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR] = sharedSettings.dontShowPushNotificationSelector
|
||||
preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] = sharedSettings.dontAskForNotificationPermissions
|
||||
preferences[UI_FEATURE_SET] = sharedSettings.featureSet.name
|
||||
preferences[UI_GALLERY_SET] = sharedSettings.gallerySet.name
|
||||
preferences[UI_PROPOSE_AI_IMPROVEMENTS] = sharedSettings.automaticallyProposeAiImprovements.name
|
||||
preferences[UI_USE_TRACKED_BROADCASTS] = sharedSettings.useTrackedBroadcasts.name
|
||||
preferences[UI_AUTOMATICALLY_CREATE_DRAFTS] = sharedSettings.automaticallyCreateDrafts.name
|
||||
preferences[UI_SHOW_HOME_NEW_THREADS_TAB] = sharedSettings.showHomeNewThreadsTab
|
||||
preferences[UI_SHOW_HOME_CONVERSATIONS_TAB] = sharedSettings.showHomeConversationsTab
|
||||
preferences[UI_SHOW_HOME_EVERYTHING_TAB] = sharedSettings.showHomeEverythingTab
|
||||
preferences[UI_SHOW_PROFILE_BADGES] = sharedSettings.showProfileBadges
|
||||
preferences[UI_SHOW_PROFILE_APP_RECOMMENDATIONS] = sharedSettings.showProfileAppRecommendations
|
||||
preferences[UI_SHOW_PROFILE_ZAP_RECEIVED_FEED] = sharedSettings.showProfileZapReceivedFeed
|
||||
preferences[UI_SHOW_PROFILE_FOLLOWERS_FEED] = sharedSettings.showProfileFollowersFeed
|
||||
preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] = sharedSettings.dontShowOnchainPublicWarning
|
||||
preferences[UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT] = sharedSettings.suggestWorkoutsFromHealthConnect.name
|
||||
preferences[UI_ACCENT_COLOR] = sharedSettings.accentColor.name
|
||||
preferences[UI_FONT_FAMILY] = sharedSettings.fontFamily.name
|
||||
preferences[UI_FONT_SIZE] = sharedSettings.fontSize.name
|
||||
preferences[UI_COMPOSE_SIGNATURE] = sharedSettings.composeSignature
|
||||
preferences[UI_SHOW_ONCHAIN_WALLET] = sharedSettings.showOnchainWallet
|
||||
preferences[UI_SHOW_PAYTO_ZAP_CHIP] = sharedSettings.showPayToZapChip
|
||||
}
|
||||
context.sharedPreferencesDataStore.edit { preferences -> preferences.write(sharedSettings) }
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
// Log any errors that occur while reading the DataStore.
|
||||
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.preferences
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import okio.IOException
|
||||
|
||||
/**
|
||||
* Who this account is, and the handful of per-account settings that sat beside
|
||||
* that in the legacy file.
|
||||
*
|
||||
* [pubKeyHex] is the one value in the whole preference layer that the account
|
||||
* cannot be loaded without: the loader returns null the moment it is missing,
|
||||
* and the account disappears from the app even with its private key safe in the
|
||||
* key store. Everything here is public — a pubkey, a signer's package name, the
|
||||
* relay URLs the user typed — and the store file is already named after the
|
||||
* account's npub, so keeping it in the plain per-account store reveals nothing
|
||||
* the file name does not.
|
||||
*
|
||||
* `hasBackedUpKeys` is deliberately *not* a field here. It is written on its
|
||||
* own, by the key-backup nudge, at moments unrelated to any of these; folding
|
||||
* it into the group would mean every [AccountIdentityStore.save] carried a
|
||||
* value its caller never knew about and would flip the nudge back on. It gets
|
||||
* its own accessors below.
|
||||
*/
|
||||
data class AccountIdentity(
|
||||
val pubKeyHex: String? = null,
|
||||
val loginWithExternalSigner: Boolean = false,
|
||||
val externalSignerPackageName: String? = null,
|
||||
val localRelayServers: Set<String> = emptySet(),
|
||||
val openBackupConflictsJson: String? = null,
|
||||
)
|
||||
|
||||
/** Reads and writes [AccountIdentity] in the account's DataStore. */
|
||||
class AccountIdentityStore(
|
||||
private val store: DataStore<Preferences>,
|
||||
) {
|
||||
companion object {
|
||||
val pubKeyHex = stringPreferencesKey("nostr_pubkey")
|
||||
val loginWithExternalSigner = booleanPreferencesKey("login_with_external_signer")
|
||||
val externalSignerPackageName = stringPreferencesKey("signer_package_name")
|
||||
val localRelayServers = stringSetPreferencesKey("localRelayServers")
|
||||
val openBackupConflictsJson = stringPreferencesKey("openBackupConflicts")
|
||||
val hasBackedUpKeys = booleanPreferencesKey("has_backed_up_keys")
|
||||
|
||||
/**
|
||||
* What the `secret_keeper_<npub>` file called these, for the one-shot copy.
|
||||
*
|
||||
* `has_backed_up_keys` is carried here even though it is not part of
|
||||
* [AccountIdentity]: the copy is per *key*, not per group, and losing
|
||||
* it would put the "back up your key" nudge back in front of every
|
||||
* user who had already dismissed it.
|
||||
*/
|
||||
val legacyTable =
|
||||
LegacyKeyTable(
|
||||
"migrated.identity",
|
||||
listOf(
|
||||
LegacyStringKey("nostr_pubkey", pubKeyHex),
|
||||
LegacyBooleanKey("login_with_external_signer", loginWithExternalSigner),
|
||||
LegacyStringKey("signer_package_name", externalSignerPackageName),
|
||||
LegacyStringSetKey("localRelayServers", localRelayServers),
|
||||
LegacyStringKey("openBackupConflicts", openBackupConflictsJson),
|
||||
LegacyBooleanKey("has_backed_up_keys", hasBackedUpKeys),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun read(): Preferences =
|
||||
store.data
|
||||
.catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e }
|
||||
.first()
|
||||
|
||||
suspend fun load(): AccountIdentity {
|
||||
val prefs = read()
|
||||
|
||||
return AccountIdentity(
|
||||
pubKeyHex = prefs[pubKeyHex],
|
||||
loginWithExternalSigner = prefs[loginWithExternalSigner] ?: false,
|
||||
externalSignerPackageName = prefs[externalSignerPackageName],
|
||||
localRelayServers = prefs[localRelayServers] ?: emptySet(),
|
||||
openBackupConflictsJson = prefs[openBackupConflictsJson],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the whole group in one edit, so a crash cannot half-apply it.
|
||||
*
|
||||
* The removes matter as much as the puts and mirror the legacy block
|
||||
* exactly: an account that drops its external signer, clears its local
|
||||
* relays or answers its last backup conflict has to end up with those keys
|
||||
* *absent*, not holding yesterday's value.
|
||||
*/
|
||||
suspend fun save(value: AccountIdentity) {
|
||||
store.edit { prefs ->
|
||||
value.pubKeyHex.let { if (it != null) prefs[pubKeyHex] = it else prefs.remove(pubKeyHex) }
|
||||
prefs[loginWithExternalSigner] = value.loginWithExternalSigner
|
||||
value.externalSignerPackageName.let { if (it != null) prefs[externalSignerPackageName] = it else prefs.remove(externalSignerPackageName) }
|
||||
value.localRelayServers.let { if (it.isNotEmpty()) prefs[localRelayServers] = it else prefs.remove(localRelayServers) }
|
||||
value.openBackupConflictsJson.let { if (it != null) prefs[openBackupConflictsJson] = it else prefs.remove(openBackupConflictsJson) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True unless a freshly generated account still has its key only in the
|
||||
* app. Absent means true: every account logged in from an existing nsec,
|
||||
* bunker or external signer already holds its key elsewhere and must not
|
||||
* be nudged.
|
||||
*/
|
||||
suspend fun hasBackedUpKeys(): Boolean = read()[hasBackedUpKeys] ?: true
|
||||
|
||||
suspend fun setHasBackedUpKeys(value: Boolean) {
|
||||
store.edit { prefs -> prefs[hasBackedUpKeys] = value }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Falls back to [legacy] as a whole when this identity cannot be used.
|
||||
*
|
||||
* The test is [AccountIdentity.pubKeyHex], and the fallback is all-or-nothing
|
||||
* on purpose. A missing pubkey means the store answered from
|
||||
* `emptyPreferences()` — its file is unreadable, or the one-shot copy never
|
||||
* ran — and in that state the other fields are equally untrustworthy: an
|
||||
* absent boolean and a `false` one are the same value here, so merging field
|
||||
* by field would quietly report an external-signer account as a local one.
|
||||
* A pubkey present means the store is live and authoritative.
|
||||
*/
|
||||
fun AccountIdentity.orIfUnusable(legacy: () -> AccountIdentity): AccountIdentity = if (pubKeyHex != null) this else legacy()
|
||||
+22
@@ -68,6 +68,28 @@ class DialogDismissalStore(
|
||||
val mutedPublicChats = stringSetPreferencesKey("mutedPublicChats")
|
||||
val hasDonatedInVersion = stringSetPreferencesKey("hasDonatedInVersion")
|
||||
val viewedPollResultNoteIdsJson = stringPreferencesKey("viewedPollResultNoteIds")
|
||||
|
||||
/**
|
||||
* What the `secret_keeper_<npub>` file called these, for the one-shot copy.
|
||||
*
|
||||
* Five of the nine were renamed on the way in, so the pairs below are
|
||||
* not derivable from either side alone.
|
||||
*/
|
||||
val legacyTable =
|
||||
LegacyKeyTable(
|
||||
"migrated.dialogDismissal",
|
||||
listOf(
|
||||
LegacyBooleanKey("hide_delete_request_dialog", hideDeleteRequestDialog),
|
||||
LegacyBooleanKey("hide_block_alert_dialog", hideBlockAlertDialog),
|
||||
LegacyBooleanKey("hide_nip24_warning_dialog", hideNip17WarningDialog),
|
||||
LegacyBooleanKey("hideCommunityRulesViolations", hideCommunityRulesViolations),
|
||||
LegacyStringSetKey("dismissed_poll_note_ids", dismissedPollNoteIds),
|
||||
LegacyStringSetKey("dismissed_channel_invites", dismissedChannelInvites),
|
||||
LegacyStringSetKey("muted_public_chats", mutedPublicChats),
|
||||
LegacyStringSetKey("has_donated_in_version", hasDonatedInVersion),
|
||||
LegacyStringKey("viewed_poll_result_note_ids", viewedPollResultNoteIdsJson),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun load(): DialogDismissal {
|
||||
|
||||
+13
@@ -59,6 +59,19 @@ class FeedVisibilityStore(
|
||||
val relayGroupViewMode = stringPreferencesKey("relay_group_view_mode")
|
||||
val concordViewMode = stringPreferencesKey("concord_view_mode")
|
||||
val callsEnabled = booleanPreferencesKey("calls_enabled")
|
||||
|
||||
/** What the `secret_keeper_<npub>` file called these, for the one-shot copy. */
|
||||
val legacyTable =
|
||||
LegacyKeyTable(
|
||||
"migrated.feedVisibility",
|
||||
listOf(
|
||||
LegacyStringKey("disabled_chat_feeds", disabledChatFeeds),
|
||||
LegacyStringKey("disabled_home_feed_types", disabledHomeFeedTypes),
|
||||
LegacyStringKey("relay_group_view_mode", relayGroupViewMode),
|
||||
LegacyStringKey("concord_view_mode", concordViewMode),
|
||||
LegacyBooleanKey("calls_enabled", callsEnabled),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun load(): FeedVisibility {
|
||||
|
||||
+14
@@ -84,6 +84,20 @@ enum class LatestEventSlot(
|
||||
class LatestEventCacheStore(
|
||||
private val store: DataStore<Preferences>,
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* The one-shot copy out of `secret_keeper_<npub>`.
|
||||
*
|
||||
* Both names come off the same enum entry, so this table cannot drift
|
||||
* from the slots the store actually reads.
|
||||
*/
|
||||
val legacyTable =
|
||||
LegacyKeyTable(
|
||||
"migrated.latestEvents",
|
||||
LatestEventSlot.entries.map { LegacyStringKey(it.prefKey, it.key) },
|
||||
)
|
||||
}
|
||||
|
||||
/** Only the slots actually present; an absent slot means nothing was cached. */
|
||||
suspend fun load(): Map<LatestEventSlot, String> {
|
||||
val prefs =
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.preferences
|
||||
|
||||
import androidx.datastore.core.DataMigration
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* The older preference file a migration reads from, reduced to the four calls
|
||||
* a migration makes.
|
||||
*
|
||||
* An interface because the legacy store is an Android
|
||||
* `EncryptedSharedPreferences`, which commonMain cannot name — and because a
|
||||
* test wants to hand a migration a map rather than a file.
|
||||
*
|
||||
* Every getter returns null for an absent key rather than a default, so a
|
||||
* migration can tell "the user turned this off" from "the user never touched
|
||||
* it" — the distinction the whole copy depends on, since a key left absent
|
||||
* keeps reading as unset and falls back to its own default.
|
||||
*/
|
||||
interface LegacyPreferenceSource {
|
||||
/** Every key the file holds, used to spot ones no migration claims. */
|
||||
fun keys(): Set<String>
|
||||
|
||||
fun getBoolean(name: String): Boolean?
|
||||
|
||||
fun getString(name: String): String?
|
||||
|
||||
fun getStringSet(name: String): Set<String>?
|
||||
}
|
||||
|
||||
/**
|
||||
* One legacy key, and the [Preferences.Key] it lands on.
|
||||
*
|
||||
* The legacy name is a compatibility surface: it is the string the Android app
|
||||
* has written since its first release, and it is frequently *not* the new key's
|
||||
* name (`has_donated_in_version` became `hasDonatedInVersion`, and five of
|
||||
* [DialogDismissalStore]'s nine keys were renamed like that). So both names are
|
||||
* spelled out here rather than derived from one another.
|
||||
*/
|
||||
sealed class LegacyKey<T : Any>(
|
||||
val legacyName: String,
|
||||
val key: Preferences.Key<T>,
|
||||
) {
|
||||
abstract fun read(source: LegacyPreferenceSource): T?
|
||||
|
||||
/** Absent stays absent — see [LegacyPreferenceSource]. */
|
||||
fun copyInto(
|
||||
source: LegacyPreferenceSource,
|
||||
out: MutablePreferences,
|
||||
) {
|
||||
read(source)?.let { out[key] = it }
|
||||
}
|
||||
}
|
||||
|
||||
class LegacyBooleanKey(
|
||||
legacyName: String,
|
||||
key: Preferences.Key<Boolean>,
|
||||
) : LegacyKey<Boolean>(legacyName, key) {
|
||||
override fun read(source: LegacyPreferenceSource) = source.getBoolean(legacyName)
|
||||
}
|
||||
|
||||
class LegacyStringKey(
|
||||
legacyName: String,
|
||||
key: Preferences.Key<String>,
|
||||
) : LegacyKey<String>(legacyName, key) {
|
||||
override fun read(source: LegacyPreferenceSource) = source.getString(legacyName)
|
||||
}
|
||||
|
||||
class LegacyStringSetKey(
|
||||
legacyName: String,
|
||||
key: Preferences.Key<Set<String>>,
|
||||
) : LegacyKey<Set<String>>(legacyName, key) {
|
||||
override fun read(source: LegacyPreferenceSource) = source.getStringSet(legacyName)
|
||||
}
|
||||
|
||||
/**
|
||||
* The keys one migration carries, as data.
|
||||
*
|
||||
* The point of the table is that the copy and the later *check* that the copy
|
||||
* happened read from the same list. A migration written as a block of
|
||||
* `if (legacy.contains(k)) out[key] = legacy.getBoolean(k)` lines cannot be
|
||||
* asked what it covers, so anything verifying it has to restate the list — and
|
||||
* a key added to one copy and not the other is exactly the silent hole that
|
||||
* makes deleting the legacy file unsafe.
|
||||
*
|
||||
* @param markerName the key recording, in the destination, that this copy has
|
||||
* run. Distinct per table, so several can run against one store.
|
||||
*/
|
||||
class LegacyKeyTable(
|
||||
val markerName: String,
|
||||
val keys: List<LegacyKey<*>>,
|
||||
) {
|
||||
private val marker = booleanPreferencesKey(markerName)
|
||||
|
||||
val legacyNames: Set<String> = keys.mapTo(mutableSetOf()) { it.legacyName }
|
||||
|
||||
/**
|
||||
* Builds the one-shot copy.
|
||||
*
|
||||
* [openSource] is called only when the migration actually runs, so opening
|
||||
* the legacy file is not a cost paid on every launch — decrypting an
|
||||
* `EncryptedSharedPreferences` is not free.
|
||||
*/
|
||||
fun migration(openSource: () -> LegacyPreferenceSource): DataMigration<Preferences> =
|
||||
CopyOnceMigration(markerName) { out ->
|
||||
withContext(Dispatchers.IO) {
|
||||
val source = openSource()
|
||||
keys.forEach { it.copyInto(source, out) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the copy has run against [destination].
|
||||
*
|
||||
* This, and not a value-by-value comparison, is what says the legacy keys
|
||||
* made it across. [CopyOnceMigration] writes the values and this marker as
|
||||
* one `Preferences`, which DataStore commits atomically, so the marker
|
||||
* being set means every value the copy read was written with it.
|
||||
*
|
||||
* A comparison would be the wrong question anyway: once migrated, these
|
||||
* groups are written *only* to the new store, so the legacy file is a
|
||||
* frozen snapshot and the two are expected to diverge the moment the user
|
||||
* changes a setting.
|
||||
*/
|
||||
fun hasRun(destination: Preferences): Boolean = destination[marker] == true
|
||||
}
|
||||
+11
@@ -59,6 +59,17 @@ class NotificationPrefsStore(
|
||||
val alwaysOnService = booleanPreferencesKey("always_on_notification_service")
|
||||
val showMessagesInNotifications = booleanPreferencesKey("show_messages_in_notifications")
|
||||
val splitNotificationsEnabled = booleanPreferencesKey("split_notifications_enabled")
|
||||
|
||||
/** What the `secret_keeper_<npub>` file called these, for the one-shot copy. */
|
||||
val legacyTable =
|
||||
LegacyKeyTable(
|
||||
"migrated.notificationPrefs",
|
||||
listOf(
|
||||
LegacyBooleanKey("always_on_notification_service", alwaysOnService),
|
||||
LegacyBooleanKey("show_messages_in_notifications", showMessagesInNotifications),
|
||||
LegacyBooleanKey("split_notifications_enabled", splitNotificationsEnabled),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun read(): Preferences =
|
||||
|
||||
+18
@@ -59,6 +59,24 @@ class RelayAuthStore(
|
||||
val trustReadFollows = booleanPreferencesKey("relay_auth_trust_read_follows")
|
||||
val trustMessageFollows = booleanPreferencesKey("relay_auth_trust_message_follows")
|
||||
val trustMessageStrangers = booleanPreferencesKey("relay_auth_trust_message_strangers")
|
||||
|
||||
/**
|
||||
* What the `secret_keeper_<npub>` file called these, for the one-shot copy.
|
||||
*
|
||||
* The two "trust my relays" spellings differ: the legacy key grew a
|
||||
* `_and_venues` suffix that the new one dropped.
|
||||
*/
|
||||
val legacyTable =
|
||||
LegacyKeyTable(
|
||||
"migrated.relayAuth",
|
||||
listOf(
|
||||
LegacyStringKey("default_relay_auth_policy", policyName),
|
||||
LegacyBooleanKey("relay_auth_trust_my_relays_and_venues", trustMyRelays),
|
||||
LegacyBooleanKey("relay_auth_trust_read_follows", trustReadFollows),
|
||||
LegacyBooleanKey("relay_auth_trust_message_follows", trustMessageFollows),
|
||||
LegacyBooleanKey("relay_auth_trust_message_strangers", trustMessageStrangers),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun load(): RelayAuth {
|
||||
|
||||
+14
@@ -95,6 +95,20 @@ enum class FollowListSlot(
|
||||
class TopNavFollowListStore(
|
||||
private val store: DataStore<Preferences>,
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* The one-shot copy out of `secret_keeper_<npub>`.
|
||||
*
|
||||
* Both names come off the same enum entry, so this table cannot drift
|
||||
* from the slots the store actually reads.
|
||||
*/
|
||||
val legacyTable =
|
||||
LegacyKeyTable(
|
||||
"migrated.followLists",
|
||||
FollowListSlot.entries.map { LegacyStringKey(it.prefKey, it.key) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every slot's current filter, falling back to [FollowListSlot.default]
|
||||
* where the key is unset or unreadable.
|
||||
|
||||
+14
@@ -58,6 +58,20 @@ class UploadSettingsStore(
|
||||
val useLocalBlossomCache = booleanPreferencesKey("useLocalBlossomCache")
|
||||
val localBlossomCacheProfilePicturesOnly = booleanPreferencesKey("localBlossomCacheProfilePicturesOnly")
|
||||
val defaultFileServerJson = stringPreferencesKey("defaultFileServer")
|
||||
|
||||
/** What the `secret_keeper_<npub>` file called these, for the one-shot copy. */
|
||||
val legacyTable =
|
||||
LegacyKeyTable(
|
||||
"migrated.uploadSettings",
|
||||
listOf(
|
||||
LegacyBooleanKey("stripLocationOnUpload", stripLocationOnUpload),
|
||||
LegacyBooleanKey("optimizeMediaOnUpload", optimizeMediaOnUpload),
|
||||
LegacyBooleanKey("mirrorUploadsToAllServers", mirrorUploadsToAllServers),
|
||||
LegacyBooleanKey("useLocalBlossomCache", useLocalBlossomCache),
|
||||
LegacyBooleanKey("localBlossomCacheProfilePicturesOnly", localBlossomCacheProfilePicturesOnly),
|
||||
LegacyStringKey("defaultFileServer", defaultFileServerJson),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun load(): UploadSettings {
|
||||
|
||||
+57
@@ -78,3 +78,60 @@ internal object AccountSecretKeys {
|
||||
*/
|
||||
const val SET_SEPARATOR = "\n"
|
||||
}
|
||||
|
||||
/**
|
||||
* The names [AccountSecrets] had in the `secret_keeper_<npub>` file, and how to
|
||||
* read a set of them back out.
|
||||
*
|
||||
* Unlike the plain-store groups, these are still written to both stores on
|
||||
* every save, so this is not only a migration source: it is what lets a check
|
||||
* read the legacy file and the current one and assert they agree before the
|
||||
* legacy file is deleted.
|
||||
*/
|
||||
object LegacyAccountSecretNames {
|
||||
const val NIP46_SIGNER_ENABLED = "nip46SignerEnabled"
|
||||
const val NIP46_BUNKER_SECRET = "nip46BunkerSecret"
|
||||
const val NIP46_TRANSPORT_KEY = "nip46TransportKey"
|
||||
const val NIP46_SEEN_IDS = "nip46SeenRequestIds"
|
||||
const val NWC_WALLETS = "nwcWallets"
|
||||
const val CLINK_DEBIT_WALLETS = "clinkDebitWallets"
|
||||
const val DEFAULT_PAYMENT_SOURCE_ID = "defaultPaymentSourceId"
|
||||
const val DEFAULT_NWC_WALLET_ID = "defaultNwcWalletId"
|
||||
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
|
||||
|
||||
/** The private key, which lives in its own store rather than in [AccountSecrets]. */
|
||||
const val NOSTR_PRIVKEY = "nostr_privkey"
|
||||
|
||||
val all =
|
||||
setOf(
|
||||
NIP46_SIGNER_ENABLED,
|
||||
NIP46_BUNKER_SECRET,
|
||||
NIP46_TRANSPORT_KEY,
|
||||
NIP46_SEEN_IDS,
|
||||
NWC_WALLETS,
|
||||
CLINK_DEBIT_WALLETS,
|
||||
DEFAULT_PAYMENT_SOURCE_ID,
|
||||
DEFAULT_NWC_WALLET_ID,
|
||||
ZAP_PAYMENT_REQUEST_SERVER,
|
||||
NOSTR_PRIVKEY,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The secrets as the legacy file holds them.
|
||||
*
|
||||
* Absent keys become the same defaults the loader has always applied, so this
|
||||
* is directly comparable with what the current store returns.
|
||||
*/
|
||||
fun readLegacyAccountSecrets(source: LegacyPreferenceSource) =
|
||||
AccountSecrets(
|
||||
nip46SignerEnabled = source.getBoolean(LegacyAccountSecretNames.NIP46_SIGNER_ENABLED) ?: false,
|
||||
nip46BunkerSecret = source.getString(LegacyAccountSecretNames.NIP46_BUNKER_SECRET) ?: "",
|
||||
nip46TransportKey = source.getString(LegacyAccountSecretNames.NIP46_TRANSPORT_KEY) ?: "",
|
||||
nip46SeenRequestIds = source.getStringSet(LegacyAccountSecretNames.NIP46_SEEN_IDS) ?: emptySet(),
|
||||
nwcWalletsJson = source.getString(LegacyAccountSecretNames.NWC_WALLETS),
|
||||
clinkDebitWalletsJson = source.getString(LegacyAccountSecretNames.CLINK_DEBIT_WALLETS),
|
||||
defaultPaymentSourceId = source.getString(LegacyAccountSecretNames.DEFAULT_PAYMENT_SOURCE_ID),
|
||||
legacyDefaultNwcWalletId = source.getString(LegacyAccountSecretNames.DEFAULT_NWC_WALLET_ID),
|
||||
legacyZapPaymentRequestServer = source.getString(LegacyAccountSecretNames.ZAP_PAYMENT_REQUEST_SERVER),
|
||||
)
|
||||
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.preferences
|
||||
|
||||
import androidx.datastore.core.DataMigration
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.io.File
|
||||
|
||||
class AccountIdentityStoreTest {
|
||||
@get:Rule
|
||||
val folder = TemporaryFolder()
|
||||
|
||||
private var seq = 0
|
||||
|
||||
private fun raw(migrations: List<DataMigration<Preferences>> = emptyList()): DataStore<Preferences> {
|
||||
val file = File(folder.root, "identity_${seq++}.preferences_pb")
|
||||
return PreferenceDataStoreFactory.createWithPath(
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
|
||||
migrations = migrations,
|
||||
produceFile = { file.toOkioPath() },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultsMatchTheLegacyOnes() =
|
||||
runTest {
|
||||
val loaded = AccountIdentityStore(raw()).load()
|
||||
|
||||
assertNull(loaded.pubKeyHex)
|
||||
assertEquals(false, loaded.loginWithExternalSigner)
|
||||
assertNull(loaded.externalSignerPackageName)
|
||||
assertEquals(emptySet<String>(), loaded.localRelayServers)
|
||||
assertNull(loaded.openBackupConflictsJson)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun roundTrip() =
|
||||
runTest {
|
||||
val store = AccountIdentityStore(raw())
|
||||
val value =
|
||||
AccountIdentity(
|
||||
pubKeyHex = "aabbcc",
|
||||
loginWithExternalSigner = true,
|
||||
externalSignerPackageName = "com.greenart7c3.nostrsigner",
|
||||
localRelayServers = setOf("ws://localhost:4869"),
|
||||
openBackupConflictsJson = """[["a","b","c"]]""",
|
||||
)
|
||||
|
||||
store.save(value)
|
||||
|
||||
assertEquals(value, store.load())
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropping an external signer, clearing the local relays or answering the
|
||||
* last backup conflict has to leave those keys absent — not holding
|
||||
* yesterday's value.
|
||||
*/
|
||||
@Test
|
||||
fun savingEmptyValuesClearsWhatWasThere() =
|
||||
runTest {
|
||||
val store = AccountIdentityStore(raw())
|
||||
store.save(
|
||||
AccountIdentity(
|
||||
pubKeyHex = "aabbcc",
|
||||
loginWithExternalSigner = true,
|
||||
externalSignerPackageName = "com.example.signer",
|
||||
localRelayServers = setOf("ws://localhost:4869"),
|
||||
openBackupConflictsJson = "[]",
|
||||
),
|
||||
)
|
||||
|
||||
store.save(AccountIdentity(pubKeyHex = "aabbcc"))
|
||||
|
||||
val loaded = store.load()
|
||||
assertEquals("aabbcc", loaded.pubKeyHex)
|
||||
assertEquals(false, loaded.loginWithExternalSigner)
|
||||
assertNull(loaded.externalSignerPackageName)
|
||||
assertEquals(emptySet<String>(), loaded.localRelayServers)
|
||||
assertNull(loaded.openBackupConflictsJson)
|
||||
}
|
||||
|
||||
/** Absent means "already backed up elsewhere", so the nudge stays off. */
|
||||
@Test
|
||||
fun hasBackedUpKeysDefaultsToTrue() =
|
||||
runTest {
|
||||
assertEquals(true, AccountIdentityStore(raw()).hasBackedUpKeys())
|
||||
}
|
||||
|
||||
/**
|
||||
* The nudge writes this on its own. A group save must not carry a value
|
||||
* its caller never knew about and flip the nudge back on.
|
||||
*/
|
||||
@Test
|
||||
fun aGroupSaveLeavesHasBackedUpKeysAlone() =
|
||||
runTest {
|
||||
val store = AccountIdentityStore(raw())
|
||||
store.setHasBackedUpKeys(false)
|
||||
|
||||
store.save(AccountIdentity(pubKeyHex = "aabbcc", localRelayServers = setOf("ws://x")))
|
||||
|
||||
assertEquals(false, store.hasBackedUpKeys())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theLegacyCopyCarriesEveryFieldIncludingTheBackupFlag() =
|
||||
runTest {
|
||||
val legacy =
|
||||
FakeLegacySource(
|
||||
mapOf(
|
||||
"nostr_pubkey" to "aabbcc",
|
||||
"login_with_external_signer" to true,
|
||||
"signer_package_name" to "com.example.signer",
|
||||
"localRelayServers" to setOf("ws://localhost:4869"),
|
||||
"openBackupConflicts" to """[["a","b","c"]]""",
|
||||
"has_backed_up_keys" to false,
|
||||
),
|
||||
)
|
||||
|
||||
val store = AccountIdentityStore(raw(listOf(AccountIdentityStore.legacyTable.migration { legacy })))
|
||||
|
||||
assertEquals(
|
||||
AccountIdentity(
|
||||
pubKeyHex = "aabbcc",
|
||||
loginWithExternalSigner = true,
|
||||
externalSignerPackageName = "com.example.signer",
|
||||
localRelayServers = setOf("ws://localhost:4869"),
|
||||
openBackupConflictsJson = """[["a","b","c"]]""",
|
||||
),
|
||||
store.load(),
|
||||
)
|
||||
assertEquals(false, store.hasBackedUpKeys())
|
||||
}
|
||||
|
||||
/**
|
||||
* The fallback is all-or-nothing, and that is the point: with no pubkey
|
||||
* the store answered from `emptyPreferences()`, where an absent boolean and
|
||||
* a false one are the same value. Merging field by field would report an
|
||||
* external-signer account as a local one — and a local one has no signer,
|
||||
* so the account would go read-only.
|
||||
*/
|
||||
@Test
|
||||
fun anIdentityWithoutAPubKeyFallsBackWholesale() {
|
||||
val legacy =
|
||||
AccountIdentity(
|
||||
pubKeyHex = "aabbcc",
|
||||
loginWithExternalSigner = true,
|
||||
externalSignerPackageName = "com.example.signer",
|
||||
)
|
||||
|
||||
assertEquals(legacy, AccountIdentity().orIfUnusable { legacy })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anIdentityWithAPubKeyIsAuthoritative() {
|
||||
val stored = AccountIdentity(pubKeyHex = "aabbcc")
|
||||
var called = false
|
||||
|
||||
val result =
|
||||
stored.orIfUnusable {
|
||||
called = true
|
||||
AccountIdentity(pubKeyHex = "ddeeff")
|
||||
}
|
||||
|
||||
assertSame(stored, result)
|
||||
assertTrue(!called)
|
||||
}
|
||||
}
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.preferences
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.job
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.io.File
|
||||
|
||||
/** A [LegacyPreferenceSource] over a map, standing in for the encrypted file. */
|
||||
class FakeLegacySource(
|
||||
private val values: Map<String, Any>,
|
||||
) : LegacyPreferenceSource {
|
||||
var opened = 0
|
||||
private set
|
||||
|
||||
init {
|
||||
opened++
|
||||
}
|
||||
|
||||
override fun keys() = values.keys
|
||||
|
||||
override fun getBoolean(name: String) = values[name] as Boolean?
|
||||
|
||||
override fun getString(name: String) = values[name] as String?
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun getStringSet(name: String) = values[name] as Set<String>?
|
||||
}
|
||||
|
||||
class LegacyKeyTableTest {
|
||||
@get:Rule
|
||||
val folder = TemporaryFolder()
|
||||
|
||||
private val flag = booleanPreferencesKey("flag")
|
||||
private val text = stringPreferencesKey("text")
|
||||
private val bag = stringSetPreferencesKey("bag")
|
||||
|
||||
private val table =
|
||||
LegacyKeyTable(
|
||||
"migrated.test",
|
||||
listOf(
|
||||
LegacyBooleanKey("legacy_flag", flag),
|
||||
LegacyStringKey("legacy_text", text),
|
||||
LegacyStringSetKey("legacy_bag", bag),
|
||||
),
|
||||
)
|
||||
|
||||
private var seq = 0
|
||||
|
||||
private fun store(
|
||||
source: () -> LegacyPreferenceSource,
|
||||
name: String = "t_${seq++}",
|
||||
): DataStore<Preferences> {
|
||||
val file = File(folder.root, "$name.preferences_pb")
|
||||
return PreferenceDataStoreFactory.createWithPath(
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
|
||||
migrations = listOf(table.migration(source)),
|
||||
produceFile = { file.toOkioPath() },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun copiesEveryTypeAcrossTheRename() =
|
||||
runTest {
|
||||
val legacy =
|
||||
FakeLegacySource(
|
||||
mapOf(
|
||||
"legacy_flag" to true,
|
||||
"legacy_text" to "hello",
|
||||
"legacy_bag" to setOf("a", "b"),
|
||||
),
|
||||
)
|
||||
|
||||
val prefs = store({ legacy }).data.first()
|
||||
|
||||
assertEquals(true, prefs[flag])
|
||||
assertEquals("hello", prefs[text])
|
||||
assertEquals(setOf("a", "b"), prefs[bag])
|
||||
}
|
||||
|
||||
/**
|
||||
* The distinction the whole copy rests on: a key the user never set must
|
||||
* stay absent, so it keeps reading as unset and falls back to its own
|
||||
* default. Writing `false` here would turn off features whose default is on.
|
||||
*/
|
||||
@Test
|
||||
fun anAbsentLegacyKeyStaysAbsent() =
|
||||
runTest {
|
||||
val prefs = store({ FakeLegacySource(mapOf("legacy_text" to "only me")) }).data.first()
|
||||
|
||||
assertEquals("only me", prefs[text])
|
||||
assertNull(prefs[flag])
|
||||
assertNull(prefs[bag])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hasRunReportsTheMarker() =
|
||||
runTest {
|
||||
assertFalse(table.hasRun(emptyPreferences()))
|
||||
|
||||
val prefs = store({ FakeLegacySource(emptyMap()) }).data.first()
|
||||
|
||||
assertTrue(table.hasRun(prefs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypting an `EncryptedSharedPreferences` is not free, so the legacy
|
||||
* file must not be opened on launches where the copy has already run.
|
||||
*/
|
||||
@Test
|
||||
fun theLegacyFileIsNotOpenedOnceTheCopyHasRun() =
|
||||
runTest {
|
||||
var opens = 0
|
||||
val open = {
|
||||
opens++
|
||||
FakeLegacySource(mapOf("legacy_text" to "x")) as LegacyPreferenceSource
|
||||
}
|
||||
val file = File(folder.root, "reopen.preferences_pb")
|
||||
|
||||
// DataStore registers a live store per file path and only releases
|
||||
// it when the owning scope ends, so each "launch" gets its own
|
||||
// scope and gives it back.
|
||||
fun open(scope: CoroutineScope) =
|
||||
PreferenceDataStoreFactory.createWithPath(
|
||||
scope = scope,
|
||||
migrations = listOf(table.migration(open)),
|
||||
produceFile = { file.toOkioPath() },
|
||||
)
|
||||
|
||||
val first = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
assertEquals("x", open(first).data.first()[text])
|
||||
assertEquals(1, opens)
|
||||
first.cancel()
|
||||
first.coroutineContext.job.join()
|
||||
|
||||
val second = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
assertEquals("x", open(second).data.first()[text])
|
||||
assertEquals(1, opens)
|
||||
second.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* The legacy names are what the Android app has written since its first
|
||||
* release. A rename here resets that setting for everyone who had it, so
|
||||
* the list is pinned rather than regenerated.
|
||||
*/
|
||||
@Test
|
||||
fun theShippedTablesCoverTheirKeys() {
|
||||
assertEquals(
|
||||
setOf(
|
||||
"stripLocationOnUpload",
|
||||
"optimizeMediaOnUpload",
|
||||
"mirrorUploadsToAllServers",
|
||||
"useLocalBlossomCache",
|
||||
"localBlossomCacheProfilePicturesOnly",
|
||||
"defaultFileServer",
|
||||
),
|
||||
UploadSettingsStore.legacyTable.legacyNames,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
setOf(
|
||||
"nostr_pubkey",
|
||||
"login_with_external_signer",
|
||||
"signer_package_name",
|
||||
"localRelayServers",
|
||||
"openBackupConflicts",
|
||||
"has_backed_up_keys",
|
||||
),
|
||||
AccountIdentityStore.legacyTable.legacyNames,
|
||||
)
|
||||
|
||||
assertEquals(FollowListSlot.entries.size, TopNavFollowListStore.legacyTable.keys.size)
|
||||
assertEquals(LatestEventSlot.entries.size, LatestEventCacheStore.legacyTable.keys.size)
|
||||
}
|
||||
|
||||
/** Several tables share one store, so their markers must not collide. */
|
||||
@Test
|
||||
fun everyShippedMarkerIsDistinct() {
|
||||
val markers =
|
||||
listOf(
|
||||
TopNavFollowListStore.legacyTable,
|
||||
LatestEventCacheStore.legacyTable,
|
||||
UploadSettingsStore.legacyTable,
|
||||
DialogDismissalStore.legacyTable,
|
||||
RelayAuthStore.legacyTable,
|
||||
FeedVisibilityStore.legacyTable,
|
||||
NotificationPrefsStore.legacyTable,
|
||||
AccountIdentityStore.legacyTable,
|
||||
).map { it.markerName }
|
||||
|
||||
assertEquals(markers.size, markers.toSet().size)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user