package com.vitorpamplona.amethyst import android.annotation.SuppressLint import android.content.Context import android.content.SharedPreferences import com.google.gson.GsonBuilder import com.google.gson.reflect.TypeToken import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS import com.vitorpamplona.amethyst.model.RelaySetupInfo import com.vitorpamplona.amethyst.model.hexToByteArray import com.vitorpamplona.amethyst.service.model.ContactListEvent import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.ui.actions.ServersAvailable import com.vitorpamplona.amethyst.ui.note.Nip47URI import fr.acinq.secp256k1.Hex import nostr.postr.Persona import nostr.postr.toHex import nostr.postr.toNpub import java.io.File import java.util.Locale // Release mode (!BuildConfig.DEBUG) always uses encrypted preferences // To use plaintext SharedPreferences for debugging, set this to true // It will only apply in Debug builds private const val DEBUG_PLAINTEXT_PREFERENCES = false private const val DEBUG_PREFERENCES_NAME = "debug_prefs" data class AccountInfo( val npub: String, val hasPrivKey: Boolean = false ) private object PrefKeys { const val CURRENT_ACCOUNT = "currently_logged_in_account" const val SAVED_ACCOUNTS = "all_saved_accounts" const val NOSTR_PRIVKEY = "nostr_privkey" const val NOSTR_PUBKEY = "nostr_pubkey" const val FOLLOWING_CHANNELS = "following_channels" const val HIDDEN_USERS = "hidden_users" const val RELAYS = "relays" const val DONT_TRANSLATE_FROM = "dontTranslateFrom" const val LANGUAGE_PREFS = "languagePreferences" const val TRANSLATE_TO = "translateTo" const val ZAP_AMOUNTS = "zapAmounts" const val DEFAULT_ZAPTYPE = "defaultZapType" const val DEFAULT_FILE_SERVER = "defaultFileServer" const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList" const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList" const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList" const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer" const val LATEST_CONTACT_LIST = "latestContactList" const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog" const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog" val LAST_READ: (String) -> String = { route -> "last_read_route_$route" } } private val gson = GsonBuilder().create() object LocalPreferences { private const val comma = "," private var _currentAccount: String? = null private fun currentAccount(): String? { if (_currentAccount == null) { _currentAccount = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null) } return _currentAccount } private fun updateCurrentAccount(npub: String) { if (_currentAccount != npub) { _currentAccount = npub encryptedPreferences().edit().apply { putString(PrefKeys.CURRENT_ACCOUNT, npub) }.apply() } } private var _savedAccounts: List? = null private fun savedAccounts(): List { if (_savedAccounts == null) { _savedAccounts = encryptedPreferences() .getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(comma) ?: listOf() } return _savedAccounts!! } private fun updateSavedAccounts(accounts: List) { if (_savedAccounts != accounts) { _savedAccounts = accounts encryptedPreferences().edit().apply { putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma).ifBlank { null }) }.apply() } } private val prefsDirPath: String get() = "${Amethyst.instance.filesDir.parent}/shared_prefs/" private fun addAccount(npub: String) { val accounts = savedAccounts().toMutableList() if (npub !in accounts) { accounts.add(npub) updateSavedAccounts(accounts) } } private fun setCurrentAccount(account: Account) { val npub = account.userProfile().pubkeyNpub() updateCurrentAccount(npub) addAccount(npub) } fun switchToAccount(npub: String) { updateCurrentAccount(npub) } /** * Removes the account from the app level shared preferences */ private fun removeAccount(npub: String) { val accounts = savedAccounts().toMutableList() if (accounts.remove(npub)) { updateSavedAccounts(accounts) } } /** * Deletes the npub-specific shared preference file */ private fun deleteUserPreferenceFile(npub: String) { val prefsDir = File(prefsDirPath) prefsDir.list()?.forEach { if (it.contains(npub)) { File(prefsDir, it).delete() } } } private fun encryptedPreferences(npub: String? = null): SharedPreferences { return if (BuildConfig.DEBUG && DEBUG_PLAINTEXT_PREFERENCES) { val preferenceFile = if (npub == null) DEBUG_PREFERENCES_NAME else "${DEBUG_PREFERENCES_NAME}_$npub" Amethyst.instance.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE) } else { return EncryptedStorage.preferences(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) { val userPrefs = encryptedPreferences(npub) userPrefs.edit().clear().commit() removeAccount(npub) deleteUserPreferenceFile(npub) if (savedAccounts().isEmpty()) { val appPrefs = encryptedPreferences() appPrefs.edit().clear().apply() } else if (currentAccount() == npub) { updateCurrentAccount(savedAccounts().elementAt(0)) } } fun updatePrefsForLogin(account: Account) { setCurrentAccount(account) saveToEncryptedStorage(account) } fun allSavedAccounts(): List { return savedAccounts().map { npub -> AccountInfo(npub = npub) } } fun saveToEncryptedStorage(account: Account) { val prefs = encryptedPreferences(account.userProfile().pubkeyNpub()) prefs.edit().apply { account.loggedIn.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHex()) } account.loggedIn.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHex()) } putStringSet(PrefKeys.FOLLOWING_CHANNELS, account.followingChannels) putStringSet(PrefKeys.HIDDEN_USERS, account.hiddenUsers) putString(PrefKeys.RELAYS, gson.toJson(account.localRelays)) putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom) putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(account.languagePreferences)) putString(PrefKeys.TRANSLATE_TO, account.translateTo) putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices)) putString(PrefKeys.DEFAULT_ZAPTYPE, gson.toJson(account.defaultZapType)) putString(PrefKeys.DEFAULT_FILE_SERVER, gson.toJson(account.defaultFileServer)) putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList) putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList) putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, account.defaultNotificationFollowList) putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest)) putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList)) putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog) putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog) }.apply() } fun loadFromEncryptedStorage(): Account? { val acc = loadFromEncryptedStorage(currentAccount()) acc?.registerObservers() return acc } fun loadFromEncryptedStorage(npub: String?): Account? { encryptedPreferences(npub).apply { val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null) val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf() val hiddenUsers = getStringSet(PrefKeys.HIDDEN_USERS, emptySet()) ?: setOf() val localRelays = gson.fromJson( getString(PrefKeys.RELAYS, "[]"), object : TypeToken>() {}.type ) ?: setOf() val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf() val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language val defaultHomeFollowList = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS val defaultStoriesFollowList = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS val defaultNotificationFollowList = getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS val zapAmountChoices = gson.fromJson( getString(PrefKeys.ZAP_AMOUNTS, "[]"), object : TypeToken>() {}.type ) ?: listOf(500L, 1000L, 5000L) val defaultZapType = gson.fromJson( getString(PrefKeys.DEFAULT_ZAPTYPE, "PUBLIC"), object : TypeToken() {}.type ) ?: LnZapEvent.ZapType.PUBLIC val defaultFileServer = gson.fromJson( getString(PrefKeys.DEFAULT_FILE_SERVER, "IMGUR"), object : TypeToken() {}.type ) ?: ServersAvailable.IMGUR val zapPaymentRequestServer = try { getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let { gson.fromJson(it, Nip47URI::class.java) } } catch (e: Throwable) { e.printStackTrace() null } val latestContactList = try { getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let { Event.gson.fromJson(it, Event::class.java) .getRefinedEvent(true) as ContactListEvent } } catch (e: Throwable) { e.printStackTrace() null } val languagePreferences = try { getString(PrefKeys.LANGUAGE_PREFS, null)?.let { gson.fromJson( it, object : TypeToken>() {}.type ) as Map } ?: mapOf() } catch (e: Throwable) { e.printStackTrace() mapOf() } val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) val a = Account( Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()), followingChannels, hiddenUsers, localRelays, dontTranslateFrom, languagePreferences, translateTo, zapAmountChoices, defaultZapType, defaultFileServer, defaultHomeFollowList, defaultStoriesFollowList, defaultNotificationFollowList, zapPaymentRequestServer, hideDeleteRequestDialog, hideBlockAlertDialog, latestContactList ) return a } } fun saveLastRead(route: String, timestampInSecs: Long) { encryptedPreferences(currentAccount()).edit().apply { putLong(PrefKeys.LAST_READ(route), timestampInSecs) }.apply() } fun loadLastRead(route: String): Long { encryptedPreferences(currentAccount()).run { return getLong(PrefKeys.LAST_READ(route), 0) } } fun migrateSingleUserPrefs() { if (currentAccount() != null) return val pubkey = encryptedPreferences().getString(PrefKeys.NOSTR_PUBKEY, null) ?: return val npub = Hex.decode(pubkey).toNpub() val stringPrefs = listOf( PrefKeys.NOSTR_PRIVKEY, PrefKeys.NOSTR_PUBKEY, PrefKeys.RELAYS, PrefKeys.LANGUAGE_PREFS, PrefKeys.TRANSLATE_TO, PrefKeys.ZAP_AMOUNTS, PrefKeys.LATEST_CONTACT_LIST ) val stringSetPrefs = listOf( PrefKeys.FOLLOWING_CHANNELS, PrefKeys.HIDDEN_USERS, PrefKeys.DONT_TRANSLATE_FROM ) encryptedPreferences().apply { val appPrefs = this encryptedPreferences(npub).edit().apply { val userPrefs = this stringPrefs.forEach { userPrefs.putString(it, appPrefs.getString(it, null)) } stringSetPrefs.forEach { userPrefs.putStringSet(it, appPrefs.getStringSet(it, null)) } userPrefs.putBoolean( PrefKeys.HIDE_DELETE_REQUEST_DIALOG, appPrefs.getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) ) }.apply() } encryptedPreferences().edit().clear().apply() addAccount(npub) updateCurrentAccount(npub) } }