Files
Amber/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt
T
greenart7c3 b465745d1a Refactor: Move language setting from per-account to global settings
This change moves the language preference from being an individual account setting to a global application-wide setting.

The `language` property has been removed from the `Account` model and added to the `AmberSettings` model. All related logic for saving, loading, and applying the language preference has been updated to reflect this new global scope.

The application now loads and applies the language setting on startup.
2025-10-17 07:07:17 -03:00

620 lines
23 KiB
Kotlin

package com.greenart7c3.nostrsigner
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import android.util.LruCache
import androidx.compose.runtime.Immutable
import androidx.core.content.edit
import com.greenart7c3.nostrsigner.models.Account
import com.greenart7c3.nostrsigner.models.AmberSettings
import com.greenart7c3.nostrsigner.models.defaultAppRelays
import com.greenart7c3.nostrsigner.models.defaultIndexerRelays
import com.greenart7c3.nostrsigner.okhttp.HttpClientManager
import com.greenart7c3.nostrsigner.ui.parseBiometricsTimeType
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import java.io.File
import kotlin.collections.component1
import kotlin.collections.component2
import kotlin.collections.iterator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
private enum class PrefKeys(val key: String) {
NOSTR_PRIVKEY("nostr_privkey"),
NOSTR_PUBKEY("nostr_pubkey"),
ACCOUNT_NAME("account_name"),
SIGN_POLICY("default_sign_policy"),
SEED_WORDS("seed_words"),
PROFILE_URL("profile_url"),
LAST_METADATA_UPDATE("last_metadata_update"),
LAST_CHECK("last_check"),
DID_BACKUP("did_backup"),
}
private enum class SettingsKeys(val key: String) {
DEFAULT_RELAYS("default_relays"),
DEFAULT_PROFILE_RELAYS("default_profile_relays"),
LAST_BIOMETRICS_TIME("last_biometrics_time"),
USE_AUTH("use_auth"),
BIOMETRICS_TYPE("biometrics_type"),
USE_PIN("use_pin"),
RATIONALE("rationale"),
CURRENT_ACCOUNT("currently_logged_in_account"),
PIN("pin"),
SAVED_ACCOUNTS("all_saved_accounts"),
USE_PROXY("use_proxy"),
PROXY_PORT("proxy_port"),
BATERRY_OPTIMIZATION("battery_optimization"),
KILL_SWITCH("kill_switch"),
LANGUAGE_PREFS("languagePreferences"),
}
@Immutable
data class AccountInfo(
val npub: String,
val hasPrivKey: Boolean,
)
object LocalPreferences {
private const val COMMA = ","
private var currentAccount: String? = null
private var accountCache = LruCache<String, Account>(10)
fun allLegacySavedAccounts(context: Context): List<AccountInfo> {
return legacySavedAccounts(context).map { npub ->
AccountInfo(
npub,
true,
)
}.toSet().toList()
}
fun allSavedAccounts(context: Context): List<AccountInfo> {
return savedAccounts(context).map { npub ->
AccountInfo(
npub,
true,
)
}.toSet().toList()
}
fun currentAccount(context: Context): String? {
if (currentAccount == null) {
Log.d(Amber.TAG, "currentAccount is null fetching from shared preferences")
currentAccount = sharedPrefs(context).getString(SettingsKeys.CURRENT_ACCOUNT.key, null)
if (currentAccount == null) {
Log.d(Amber.TAG, "currentAccount is null fetching from all saved accounts")
currentAccount = allSavedAccounts(context).firstOrNull()?.npub
}
}
return currentAccount
}
private fun updateCurrentAccount(context: Context, npub: String) {
if (currentAccount != npub) {
currentAccount = npub
sharedPrefs(context).edit {
apply {
putString(SettingsKeys.CURRENT_ACCOUNT.key, npub)
}
}
}
}
fun shouldShowRationale(context: Context): Boolean? {
if (!sharedPrefs(context).contains(SettingsKeys.RATIONALE.key)) {
return null
}
return sharedPrefs(context).getBoolean(SettingsKeys.RATIONALE.key, true)
}
fun updateShouldShowRationale(context: Context, value: Boolean) {
sharedPrefs(context).edit {
apply {
putBoolean(SettingsKeys.RATIONALE.key, value)
}
}
}
fun saveSettingsToEncryptedStorage(settings: AmberSettings) {
val context = Amber.instance
sharedPrefs(context).edit {
apply {
putStringSet(SettingsKeys.DEFAULT_RELAYS.key, settings.defaultRelays.map { it.url }.toSet())
putStringSet(SettingsKeys.DEFAULT_PROFILE_RELAYS.key, settings.defaultProfileRelays.map { it.url }.toSet())
putLong(SettingsKeys.LAST_BIOMETRICS_TIME.key, settings.lastBiometricsTime)
putBoolean(SettingsKeys.USE_AUTH.key, settings.useAuth)
putInt(SettingsKeys.BIOMETRICS_TYPE.key, settings.biometricsTimeType.screenCode)
putBoolean(SettingsKeys.USE_PIN.key, settings.usePin)
putBoolean(SettingsKeys.USE_PROXY.key, settings.useProxy)
putInt(SettingsKeys.PROXY_PORT.key, settings.proxyPort)
putBoolean(SettingsKeys.KILL_SWITCH.key, settings.killSwitch.value)
putString(SettingsKeys.LANGUAGE_PREFS.key, settings.language)
}
}
}
fun getBatteryOptimization(context: Context): Boolean {
return sharedPrefs(context).getBoolean(SettingsKeys.BATERRY_OPTIMIZATION.key, false)
}
fun updateBatteryOptimization(context: Context, value: Boolean) {
sharedPrefs(context).edit {
apply {
putBoolean(SettingsKeys.BATERRY_OPTIMIZATION.key, value)
}
}
}
fun setLastCheck(context: Context, npub: String, time: Long) {
sharedPrefs(context, npub).edit {
apply {
putLong(PrefKeys.LAST_CHECK.key, time)
}
}
}
fun setLastMetadataUpdate(context: Context, npub: String, time: Long) {
sharedPrefs(context, npub).edit {
apply {
putLong(PrefKeys.LAST_METADATA_UPDATE.key, time)
}
}
}
fun loadPinFromEncryptedStorage(): String? {
val context = Amber.instance
return sharedPrefs(context).getString(SettingsKeys.PIN.key, null)
}
fun savePinToEncryptedStorage(pin: String?) {
val context = Amber.instance
sharedPrefs(context).edit {
apply {
if (pin == null) {
remove(SettingsKeys.PIN.key)
} else {
putString(SettingsKeys.PIN.key, pin)
}
}
}
}
suspend fun reloadApp() {
val context = Amber.instance
currentAccount = null
savedAccounts = null
accountCache.evictAll()
allSavedAccounts(context).forEach {
loadFromEncryptedStorage(context, it.npub)
}
context.settings = loadSettingsFromEncryptedStorage(context)
}
fun loadSettingsFromEncryptedStorage(context: Context = Amber.instance): AmberSettings {
checkNotInMainThread()
sharedPrefs(context).apply {
val proxyPort = getInt(SettingsKeys.PROXY_PORT.key, 9050)
HttpClientManager.setDefaultProxyOnPort(proxyPort)
return AmberSettings(
defaultRelays = getStringSet(SettingsKeys.DEFAULT_RELAYS.key, null)?.mapNotNull {
RelayUrlNormalizer.normalizeOrNull(it)
} ?: defaultAppRelays,
defaultProfileRelays = getStringSet(SettingsKeys.DEFAULT_PROFILE_RELAYS.key, null)?.mapNotNull {
RelayUrlNormalizer.normalizeOrNull(it)
} ?: defaultIndexerRelays,
lastBiometricsTime = getLong(SettingsKeys.LAST_BIOMETRICS_TIME.key, 0),
useAuth = getBoolean(SettingsKeys.USE_AUTH.key, false),
biometricsTimeType = parseBiometricsTimeType(getInt(SettingsKeys.BIOMETRICS_TYPE.key, 0)),
usePin = getBoolean(SettingsKeys.USE_PIN.key, false),
useProxy = getBoolean(SettingsKeys.USE_PROXY.key, false),
proxyPort = getInt(SettingsKeys.PROXY_PORT.key, 9050),
killSwitch = MutableStateFlow(getBoolean(SettingsKeys.KILL_SWITCH.key, false)),
language = getString(SettingsKeys.LANGUAGE_PREFS.key, null),
)
}
}
private var savedAccounts: List<String>? = null
@Suppress("DEPRECATION")
private fun legacySavedAccounts(context: Context): List<String> {
return encryptedPreferences(context)
.getString(SettingsKeys.SAVED_ACCOUNTS.key, null)?.split(COMMA) ?: listOf()
}
private fun savedAccounts(context: Context): List<String> {
if (savedAccounts == null) {
savedAccounts = sharedPrefs(context)
.getString(SettingsKeys.SAVED_ACCOUNTS.key, null)?.split(COMMA) ?: listOf()
}
return savedAccounts!!
}
private fun updateSavedAccounts(context: Context, accounts: List<String>) {
if (savedAccounts != accounts) {
savedAccounts = accounts
sharedPrefs(context).edit {
apply {
putString(SettingsKeys.SAVED_ACCOUNTS.key, accounts.joinToString(COMMA).ifBlank { null })
}
}
}
}
private fun getDirPath(context: Context): String {
return "${context.filesDir.parent}/shared_prefs/"
}
private fun addAccount(context: Context, npub: String) {
val accounts = savedAccounts(context).toMutableList()
if (npub !in accounts) {
accounts.add(npub)
updateSavedAccounts(context, accounts)
}
}
private fun setCurrentAccount(context: Context, account: Account) {
updateCurrentAccount(context, account.npub)
addAccount(context, account.npub)
}
fun switchToAccount(context: Context, npub: String) {
updateCurrentAccount(context, npub)
}
fun containsAccount(context: Context, npub: String): Boolean {
return savedAccounts(context).contains(npub)
}
/**
* Removes the account from the app level shared preferences
*/
private fun removeAccount(context: Context, npub: String) {
val accounts = savedAccounts(context).toMutableList()
if (accounts.remove(npub)) {
updateSavedAccounts(context, accounts)
}
}
/**
* Deletes the npub-specific shared preference file
*/
private fun deleteUserPreferenceFile(context: Context, npub: String) {
val prefsDir = File(getDirPath(context))
prefsDir.list()?.forEach {
if (it.contains(npub)) {
try {
val result = File(prefsDir, it).delete()
Log.d(Amber.TAG, "deleted $it: $result")
} catch (e: Exception) {
Log.d(Amber.TAG, "failed to delete $it", e)
}
}
}
}
fun deleteLegacyUserPreferenceFile(context: Context, npub: String) {
val prefsDir = File(getDirPath(context))
prefsDir.list()?.forEach {
if (it.contains(npub) && it.startsWith("secret_keeper")) {
try {
val result = File(prefsDir, it).delete()
Log.d(Amber.TAG, "deleted $it: $result")
} catch (e: Exception) {
Log.d(Amber.TAG, "failed to delete $it", e)
}
}
}
}
fun existsLegacySettings(context: Context): Boolean {
val prefsDir = File(getDirPath(context))
var result = false
prefsDir.list()?.forEach {
if (it == "secret_keeper.xml") {
result = true
}
}
return result
}
fun deleteSettingsPreferenceFile(context: Context) {
val prefsDir = File(getDirPath(context))
prefsDir.list()?.forEach {
if (it == "secret_keeper.xml") {
try {
val result = File(prefsDir, it).delete()
Log.d(Amber.TAG, "deleted $it: $result")
} catch (e: Exception) {
Log.d(Amber.TAG, "failed to delete $it", e)
}
}
}
}
@Deprecated("Use sharedPrefs instead")
private fun encryptedPreferences(
context: Context,
npub: String? = null,
): SharedPreferences {
return EncryptedStorage.preferences(npub, context)
}
private fun sharedPrefs(
context: Context,
npub: String? = null,
): SharedPreferences {
val preferenceFile = if (npub == null) "prefs" else "prefs_$npub"
return context.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE)
}
private suspend fun deleteUserDataStoreFile(context: Context, npub: String) {
DataStoreAccess.clearCacheForNpub(context, npub)
}
/**
* Clears the preferences for a given npub, deletes the preferences xml file,
* and switches the user to the first account in the list if it exists
*
* We need to use `commit()` to write changes to disk and release the file
* lock so that it can be deleted. If we use `apply()` there is a race
* condition and the file will probably not be deleted
*/
@SuppressLint("ApplySharedPref")
fun updatePrefsForLogout(npub: String, context: Context): Boolean {
accountCache.remove(npub)
val userPrefs = sharedPrefs(context, npub)
userPrefs.edit(commit = true) { clear() }
removeAccount(context, npub)
deleteUserPreferenceFile(context, npub)
runBlocking { deleteUserDataStoreFile(context, npub) }
if (savedAccounts(context).isEmpty()) {
val appPrefs = sharedPrefs(context)
appPrefs.edit(commit = true) { clear() }
return true
} else if (currentAccount(context) == npub) {
updateCurrentAccount(context, savedAccounts(context).elementAt(0))
return false
}
return false
}
fun updatePrefsForLogin(context: Context, account: Account) {
setCurrentAccount(context, account)
Amber.instance.applicationIOScope.launch {
saveToEncryptedStorage(context, account)
}
}
suspend fun saveToEncryptedStorage(context: Context, account: Account) {
val prefs = sharedPrefs(context = context, account.npub)
prefs.edit {
apply {
account.signer.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY.key, it.toHexKey()) }
putString(PrefKeys.ACCOUNT_NAME.key, account.name.value)
putString(PrefKeys.PROFILE_URL.key, account.picture.value)
putInt(PrefKeys.SIGN_POLICY.key, account.signPolicy)
putBoolean(PrefKeys.DID_BACKUP.key, account.didBackup)
}
}
account.signer.keyPair.privKey?.toHexKey()?.let {
DataStoreAccess.saveEncryptedKey(context, account.npub, DataStoreAccess.NOSTR_PRIVKEY, it)
}
DataStoreAccess.saveEncryptedKey(
context,
account.npub,
DataStoreAccess.SEED_WORDS,
account.seedWords.joinToString(" "),
)
accountCache.put(account.npub, account)
}
suspend fun loadFromEncryptedStorage(context: Context): Account? {
currentAccount(context)?.let {
return loadFromEncryptedStorage(context, it)
}
return null
}
suspend fun allAccounts(context: Context): List<Account> {
val accountInfos = allSavedAccounts(context)
return accountInfos.mapNotNull {
loadFromEncryptedStorage(context, it.npub)
}
}
fun loadFromEncryptedStorageSync(context: Context, npub: String? = null): Account? {
if (savedAccounts == null || accountCache.size() == 0 || savedAccounts?.size != accountCache.size()) {
Log.d(Amber.TAG, "accountCache is null loading accounts")
runBlocking {
allSavedAccounts(context).forEach {
loadFromEncryptedStorage(context, it.npub)
}
}
}
if (npub == null) {
val currentAccount = currentAccount(context) ?: return null
return accountCache[currentAccount]
}
return accountCache[npub]
}
fun setAccountName(
context: Context,
npub: String,
value: String,
) {
sharedPrefs(context, npub).edit {
apply {
putString(PrefKeys.ACCOUNT_NAME.key, value)
}
}
accountCache.get(npub)?.name?.tryEmit(value)
}
fun getAccountName(context: Context, npub: String): String {
sharedPrefs(context, npub).apply {
return getString(PrefKeys.ACCOUNT_NAME.key, "") ?: ""
}
}
fun updateProxy(
context: Context,
useProxy: Boolean,
port: Int,
) {
sharedPrefs(context).edit {
apply {
putBoolean(SettingsKeys.USE_PROXY.key, useProxy)
putInt(SettingsKeys.PROXY_PORT.key, port)
}
}
Amber.instance.settings = loadSettingsFromEncryptedStorage()
HttpClientManager.setDefaultProxyOnPort(port)
}
suspend fun loadFromEncryptedStorage(context: Context, npub: String): Account? {
if (accountCache.get(npub) != null) {
return accountCache.get(npub)
}
sharedPrefs(context, npub).apply {
val pubKey = getString(PrefKeys.NOSTR_PUBKEY.key, null) ?: return null
val privKey = DataStoreAccess.getEncryptedKey(context, npub, DataStoreAccess.NOSTR_PRIVKEY)
val name = getString(PrefKeys.ACCOUNT_NAME.key, "") ?: ""
val picture = getString(PrefKeys.PROFILE_URL.key, "") ?: ""
val signPolicy = getInt(PrefKeys.SIGN_POLICY.key, 1)
val savedSeedWords = DataStoreAccess.getEncryptedKey(context, npub, DataStoreAccess.SEED_WORDS)
val seedWords = savedSeedWords?.split(" ")?.toSet() ?: emptySet()
val didBackup = getBoolean(PrefKeys.DID_BACKUP.key, true)
val account =
Account(
signer = NostrSignerInternal(KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray())),
name = MutableStateFlow(name),
picture = MutableStateFlow(picture),
signPolicy = signPolicy,
seedWords = seedWords,
didBackup = didBackup,
)
accountCache.put(npub, account)
return account
}
}
suspend fun didMigrateFromLegacyStorage(context: Context, npub: String): Boolean {
val existingPrivKey = DataStoreAccess.getEncryptedKey(context, npub, DataStoreAccess.NOSTR_PRIVKEY)
return !existingPrivKey.isNullOrBlank()
}
@Suppress("DEPRECATION")
suspend fun migrateFromSharedPrefs(context: Context, npub: String) {
withContext(Dispatchers.IO) {
if (didMigrateFromLegacyStorage(context, npub)) return@withContext
val legacyPrefs = encryptedPreferences(context, npub)
val privKey = legacyPrefs.getString(PrefKeys.NOSTR_PRIVKEY.key, null)
if (privKey.isNullOrBlank()) {
return@withContext
}
val seedWords = legacyPrefs.getString(PrefKeys.SEED_WORDS.key, null)
DataStoreAccess.saveEncryptedKey(context, npub, DataStoreAccess.NOSTR_PRIVKEY, privKey)
if (!seedWords.isNullOrBlank()) {
DataStoreAccess.saveEncryptedKey(context, npub, DataStoreAccess.SEED_WORDS, seedWords)
}
legacyPrefs.edit {
apply {
remove(PrefKeys.NOSTR_PRIVKEY.key)
remove(PrefKeys.SEED_WORDS.key)
}
}
migrateUserSharedPrefs(context, npub)
deleteLegacyUserPreferenceFile(context, npub)
}
}
@Suppress("DEPRECATION")
private fun migrateUserSharedPrefs(context: Context, npub: String) {
val legacyPrefs = encryptedPreferences(context, npub)
val sharedPrefs = sharedPrefs(context, npub)
for ((key, value) in legacyPrefs.all) {
when (value) {
is String -> sharedPrefs.edit { putString(key, value) }
is Boolean -> sharedPrefs.edit { putBoolean(key, value) }
is Int -> sharedPrefs.edit { putInt(key, value) }
is Long -> sharedPrefs.edit { putLong(key, value) }
is Float -> sharedPrefs.edit { putFloat(key, value) }
is Set<*> -> {
@Suppress("UNCHECKED_CAST")
if (value.all { it is String }) {
sharedPrefs.edit { putStringSet(key, value as Set<String>) }
}
}
else -> {
// Ignore unsupported types
}
}
}
}
@Suppress("DEPRECATION")
fun migrateSettings(context: Context) {
val settingsPrefs = sharedPrefs(context)
val legacySettingsPrefs = encryptedPreferences(context)
for ((key, value) in legacySettingsPrefs.all) {
when (value) {
is String -> settingsPrefs.edit { putString(key, value) }
is Boolean -> settingsPrefs.edit { putBoolean(key, value) }
is Int -> settingsPrefs.edit { putInt(key, value) }
is Long -> settingsPrefs.edit { putLong(key, value) }
is Float -> settingsPrefs.edit { putFloat(key, value) }
is Set<*> -> {
@Suppress("UNCHECKED_CAST")
if (value.all { it is String }) {
settingsPrefs.edit { putStringSet(key, value as Set<String>) }
}
}
else -> {
// Ignore unsupported types
}
}
}
}
fun migrateTorSettings(context: Context) {
if (sharedPrefs(context).contains(SettingsKeys.USE_PROXY.key)) return
val useProxy = allSavedAccounts(context).any {
sharedPrefs(context, it.npub).getBoolean(SettingsKeys.USE_PROXY.key, false)
}
val proxyPort: Int = allSavedAccounts(context).firstNotNullOfOrNull {
if (sharedPrefs(context, it.npub).getBoolean(SettingsKeys.USE_PROXY.key, false)) {
sharedPrefs(context, it.npub).getInt(SettingsKeys.PROXY_PORT.key, 9050)
} else {
null
}
} ?: 9050
saveSettingsToEncryptedStorage(
settings = loadSettingsFromEncryptedStorage(context).copy(
useProxy = useProxy,
proxyPort = proxyPort,
),
)
}
}