Files
Amber/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt
T
greenart7c3 3acbc08f4b Add signer caching to Account model to improve performance
- Introduce `signerCache` using a `WeakReference` and a `Mutex` to safely cache the `NostrSignerInternal` instance.
- Update `getSigner()` to check the cache before retrieving the encrypted key and creating a new signer.
- Add `clearSignerCache()` to explicitly clear the cache on logout.
2025-12-17 07:35:17 -03:00

620 lines
22 KiB
Kotlin

package com.greenart7c3.nostrsigner
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
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.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.utils.cache.LargeCache
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 = LargeCache<String, Account>()
fun allLegacySavedAccounts(context: Context): List<AccountInfo> = legacySavedAccounts(context).map { npub ->
AccountInfo(
npub,
true,
)
}.toSet().toList()
fun allSavedAccounts(context: Context): List<AccountInfo> = 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 = 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 getLastCheck(context: Context, npub: String): Long = sharedPrefs(context, npub).getLong(PrefKeys.LAST_CHECK.key, 0)
fun setLastCheck(context: Context, npub: String, time: Long) {
sharedPrefs(context, npub).edit {
apply {
putLong(PrefKeys.LAST_CHECK.key, time)
}
}
}
fun getLastMetadataUpdate(context: Context, npub: String): Long = sharedPrefs(context, npub).getLong(PrefKeys.LAST_METADATA_UPDATE.key, 0)
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.clear()
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> = 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 = "${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 = 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 = 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.get(npub)?.clearSignerCache()
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,
pubKey: String?,
privKey: String?,
seedWords: String?,
) {
setCurrentAccount(context, account)
Amber.instance.applicationIOScope.launch {
saveToEncryptedStorage(context, account, pubKey, privKey, seedWords)
}
}
suspend fun saveToEncryptedStorage(
context: Context,
account: Account,
pubKey: String?,
privKey: String?,
seedWords: String?,
) {
val prefs = sharedPrefs(context = context, account.npub)
prefs.edit {
apply {
pubKey?.let { putString(PrefKeys.NOSTR_PUBKEY.key, it) }
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)
}
}
privKey?.let {
DataStoreAccess.saveEncryptedKey(context, account.npub, DataStoreAccess.NOSTR_PRIVKEY, it)
}
seedWords?.let {
DataStoreAccess.saveEncryptedKey(
context,
account.npub,
DataStoreAccess.SEED_WORDS,
seedWords,
)
}
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.get(currentAccount)
}
return accountCache.get(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 name = getString(PrefKeys.ACCOUNT_NAME.key, "") ?: ""
val picture = getString(PrefKeys.PROFILE_URL.key, "") ?: ""
val signPolicy = getInt(PrefKeys.SIGN_POLICY.key, 1)
val didBackup = getBoolean(PrefKeys.DID_BACKUP.key, true)
val account =
Account(
hexKey = pubKey,
npub = pubKey.hexToByteArray().toNpub(),
name = MutableStateFlow(name),
picture = MutableStateFlow(picture),
signPolicy = signPolicy,
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,
),
)
}
}