Use multiple preference files for different accounts

This commit is contained in:
maxmoney21m
2023-03-12 02:18:43 +08:00
parent b7f8241a08
commit 3a2403b344
10 changed files with 278 additions and 116 deletions
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetDropDown">
<targetSelectedWithDropDown>
<Target>
<type value="QUICK_BOOT_TARGET" />
<deviceKey>
<Key>
<type value="VIRTUAL_DEVICE_PATH" />
<value value="C:\Users\aiong\.android\avd\Pixel_6_API_33.avd" />
</Key>
</deviceKey>
</Target>
</targetSelectedWithDropDown>
<timeTargetWasSelectedWithDropDown value="2023-03-11T16:38:31.686457400Z" />
</component>
</project>
+1
View File
@@ -13,6 +13,7 @@
<application <application
android:allowBackup="false" android:allowBackup="false"
android:name=".Amethyst"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@drawable/amethyst" android:icon="@drawable/amethyst"
@@ -0,0 +1,15 @@
package com.vitorpamplona.amethyst
import android.app.Application
class Amethyst : Application() {
override fun onCreate() {
super.onCreate()
instance = this
}
companion object {
lateinit var instance: Amethyst
private set
}
}
@@ -1,20 +1,22 @@
package com.vitorpamplona.amethyst package com.vitorpamplona.amethyst
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
object EncryptedStorage { object EncryptedStorage {
private const val PREFERENCES_NAME = "secret_keeper" private const val PREFERENCES_NAME = "secret_keeper"
fun preferences(context: Context): EncryptedSharedPreferences { fun preferences(npub: String? = null): EncryptedSharedPreferences {
val context = Amethyst.instance
val masterKey: MasterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS) val masterKey: MasterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build() .build()
val preferencesName = if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub"
return EncryptedSharedPreferences.create( return EncryptedSharedPreferences.create(
context, context,
PREFERENCES_NAME, preferencesName,
masterKey, masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst package com.vitorpamplona.amethyst
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
@@ -11,53 +12,77 @@ import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent
import nostr.postr.Persona import nostr.postr.Persona
import nostr.postr.toHex import nostr.postr.toHex
import nostr.postr.toNpub
import java.util.Locale import java.util.Locale
const val DEBUG_PLAINTEXT_PREFERENCES = true
data class AccountInfo(val npub: String, val current: Boolean, val displayName: String?, val profilePicture: String?) data class AccountInfo(val npub: String, val current: Boolean, val displayName: String?, val profilePicture: String?)
class LocalPreferences(context: Context) { 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 DISPLAY_NAME = "display_name"
const val PROFILE_PICTURE_URL = "profile_picture"
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 LATEST_CONTACT_LIST = "latestContactList"
const val HIDE_DELETE_REQUEST_INFO = "hideDeleteRequestInfo"
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
}
private fun prefKeysForAccount(npub: String) = object { private val gson = GsonBuilder().create()
val NOSTR_PRIVKEY = "$npub/nostr_privkey"
val NOSTR_PUBKEY = "$npub/nostr_pubkey" object LocalPreferences {
val DISPLAY_NAME = "$npub/display_name" private var currentAccount: String?
val PROFILE_PICTURE_URL = "$npub/profile_picture" get() = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
val FOLLOWING_CHANNELS = "$npub/following_channels" set(npub) {
val HIDDEN_USERS = "$npub/hidden_users" val prefs = encryptedPreferences()
val RELAYS = "$npub/relays" prefs.edit().apply {
val DONT_TRANSLATE_FROM = "$npub/dontTranslateFrom" putString(PrefKeys.CURRENT_ACCOUNT, npub)
val LANGUAGE_PREFS = "$npub/languagePreferences" }.apply()
val TRANSLATE_TO = "$npub/translateTo" }
val ZAP_AMOUNTS = "$npub/zapAmounts"
val LATEST_CONTACT_LIST = "$npub/latestContactList" private val savedAccounts: Set<String>
val HIDE_DELETE_REQUEST_INFO = "$npub/hideDeleteRequestInfo" get() = encryptedPreferences().getStringSet(PrefKeys.SAVED_ACCOUNTS, null) ?: setOf()
// val LAST_READ: (String) -> String = { route -> "$npub/last_read_route_$route" }
private fun addAccount(npub: String) {
val accounts = savedAccounts.toMutableSet()
accounts.add(npub)
val prefs = encryptedPreferences()
prefs.edit().apply {
putStringSet(PrefKeys.SAVED_ACCOUNTS, accounts)
}.apply()
} }
private object PrefKeys { private fun removeAccount(npub: String) {
const val CURRENT_ACCOUNT = "currentlyLoggedInAccount" val accounts = savedAccounts.toMutableSet()
accounts.remove(npub)
// val NOSTR_PRIVKEY = "nostr_privkey" val prefs = encryptedPreferences()
// val NOSTR_PUBKEY = "nostr_pubkey" prefs.edit().apply {
// val FOLLOWING_CHANNELS = "following_channels" putStringSet(PrefKeys.SAVED_ACCOUNTS, accounts)
// val HIDDEN_USERS = "hidden_users" }.apply()
// val RELAYS = "relays"
// val DONT_TRANSLATE_FROM = "dontTranslateFrom"
// val LANGUAGE_PREFS = "languagePreferences"
// val TRANSLATE_TO = "translateTo"
// val ZAP_AMOUNTS = "zapAmounts"
// val LATEST_CONTACT_LIST = "latestContactList"
// val HIDE_DELETE_REQUEST_INFO = "hideDeleteRequestInfo"
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
} }
private val encryptedPreferences = EncryptedStorage.preferences(context) private fun encryptedPreferences(npub: String? = null): SharedPreferences {
private val gson = GsonBuilder().create() return if (DEBUG_PLAINTEXT_PREFERENCES) {
val preferenceFile = if (npub == null) "testing_only" else "testing_only_$npub"
Amethyst.instance.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE)
} else {
return EncryptedStorage.preferences(npub)
}
}
fun clearEncryptedStorage() { fun clearEncryptedStorage(npub: String? = null) {
encryptedPreferences.edit().apply { val encPrefs = encryptedPreferences(npub)
encryptedPreferences.all.keys.forEach { encPrefs.edit().apply {
encPrefs.all.keys.forEach {
remove(it) remove(it)
} }
// encryptedPreferences.all.keys.filter { // encryptedPreferences.all.keys.filter {
@@ -69,76 +94,64 @@ class LocalPreferences(context: Context) {
} }
fun findAllLocalAccounts(): List<AccountInfo> { fun findAllLocalAccounts(): List<AccountInfo> {
encryptedPreferences.apply { return savedAccounts.map { npub ->
val currentAccount = getString(PrefKeys.CURRENT_ACCOUNT, null) val prefs = encryptedPreferences(npub)
return encryptedPreferences.all.keys.filter {
it.endsWith("nostr_pubkey") AccountInfo(
}.map { npub = npub,
val npub = it.substringBefore("/") current = npub == currentAccount,
val myPrefs = prefKeysForAccount(npub) displayName = prefs.getString(PrefKeys.DISPLAY_NAME, null),
AccountInfo( profilePicture = prefs.getString(PrefKeys.PROFILE_PICTURE_URL, null)
npub, )
npub == currentAccount,
getString(myPrefs.DISPLAY_NAME, null),
getString(myPrefs.PROFILE_PICTURE_URL, null)
)
}
} }
} }
fun saveToEncryptedStorage(account: Account) { fun setCurrentAccount(account: Account) {
val npub = account.loggedIn.pubKey.toNpub() val npub = account.userProfile().pubkeyNpub()
val myPrefs = prefKeysForAccount(npub) currentAccount = npub
addAccount(npub)
encryptedPreferences.edit().apply {
putString(PrefKeys.CURRENT_ACCOUNT, npub)
account.loggedIn.privKey?.let { putString(myPrefs.NOSTR_PRIVKEY, it.toHex()) }
account.loggedIn.pubKey.let { putString(myPrefs.NOSTR_PUBKEY, it.toHex()) }
putStringSet(myPrefs.FOLLOWING_CHANNELS, account.followingChannels)
putStringSet(myPrefs.HIDDEN_USERS, account.hiddenUsers)
putString(myPrefs.RELAYS, gson.toJson(account.localRelays))
putStringSet(myPrefs.DONT_TRANSLATE_FROM, account.dontTranslateFrom)
putString(myPrefs.LANGUAGE_PREFS, gson.toJson(account.languagePreferences))
putString(myPrefs.TRANSLATE_TO, account.translateTo)
putString(myPrefs.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices))
putString(myPrefs.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
putBoolean(myPrefs.HIDE_DELETE_REQUEST_INFO, account.hideDeleteRequestInfo)
}.apply()
} }
fun saveCurrentAccountMetadata(account: Account) { fun saveToEncryptedStorage(account: Account) {
val myPrefs = prefKeysForAccount(account.loggedIn.pubKey.toNpub()) val prefs = encryptedPreferences(account.userProfile().pubkeyNpub())
prefs.edit().apply {
encryptedPreferences.edit().apply { account.loggedIn.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHex()) }
putString(myPrefs.DISPLAY_NAME, account.userProfile().toBestDisplayName()) account.loggedIn.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHex()) }
putString(myPrefs.PROFILE_PICTURE_URL, account.userProfile().profilePicture()) 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.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, account.hideDeleteRequestInfo)
putString(PrefKeys.DISPLAY_NAME, account.userProfile().toBestDisplayName())
putString(PrefKeys.PROFILE_PICTURE_URL, account.userProfile().profilePicture())
}.apply() }.apply()
} }
fun loadFromEncryptedStorage(): Account? { fun loadFromEncryptedStorage(): Account? {
encryptedPreferences.apply { encryptedPreferences(currentAccount).apply {
val npub = getString(PrefKeys.CURRENT_ACCOUNT, null) ?: return null val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null
val myPrefs = prefKeysForAccount(npub) val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
val pubKey = getString(myPrefs.NOSTR_PUBKEY, null) ?: return null val hiddenUsers = getStringSet(PrefKeys.HIDDEN_USERS, emptySet()) ?: setOf()
val privKey = getString(myPrefs.NOSTR_PRIVKEY, null)
val followingChannels = getStringSet(myPrefs.FOLLOWING_CHANNELS, null) ?: setOf()
val hiddenUsers = getStringSet(myPrefs.HIDDEN_USERS, emptySet()) ?: setOf()
val localRelays = gson.fromJson( val localRelays = gson.fromJson(
getString(myPrefs.RELAYS, "[]"), getString(PrefKeys.RELAYS, "[]"),
object : TypeToken<Set<RelaySetupInfo>>() {}.type object : TypeToken<Set<RelaySetupInfo>>() {}.type
) ?: setOf<RelaySetupInfo>() ) ?: setOf<RelaySetupInfo>()
val dontTranslateFrom = getStringSet(myPrefs.DONT_TRANSLATE_FROM, null) ?: setOf() val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
val translateTo = getString(myPrefs.TRANSLATE_TO, null) ?: Locale.getDefault().language val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
val zapAmountChoices = gson.fromJson( val zapAmountChoices = gson.fromJson(
getString(myPrefs.ZAP_AMOUNTS, "[]"), getString(PrefKeys.ZAP_AMOUNTS, "[]"),
object : TypeToken<List<Long>>() {}.type object : TypeToken<List<Long>>() {}.type
) ?: listOf(500L, 1000L, 5000L) ) ?: listOf(500L, 1000L, 5000L)
val latestContactList = try { val latestContactList = try {
getString(myPrefs.LATEST_CONTACT_LIST, null)?.let { getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let {
Event.gson.fromJson(it, Event::class.java).getRefinedEvent(true) as ContactListEvent Event.gson.fromJson(it, Event::class.java).getRefinedEvent(true) as ContactListEvent
} }
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -147,7 +160,7 @@ class LocalPreferences(context: Context) {
} }
val languagePreferences = try { val languagePreferences = try {
getString(myPrefs.LANGUAGE_PREFS, null)?.let { getString(PrefKeys.LANGUAGE_PREFS, null)?.let {
gson.fromJson(it, object : TypeToken<Map<String, String>>() {}.type) as Map<String, String> gson.fromJson(it, object : TypeToken<Map<String, String>>() {}.type) as Map<String, String>
} ?: mapOf() } ?: mapOf()
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -155,7 +168,7 @@ class LocalPreferences(context: Context) {
mapOf() mapOf()
} }
val hideDeleteRequestInfo = getBoolean(myPrefs.HIDE_DELETE_REQUEST_INFO, false) val hideDeleteRequestInfo = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, false)
return Account( return Account(
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()), Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
@@ -173,13 +186,13 @@ class LocalPreferences(context: Context) {
} }
fun saveLastRead(route: String, timestampInSecs: Long) { fun saveLastRead(route: String, timestampInSecs: Long) {
encryptedPreferences.edit().apply { encryptedPreferences(currentAccount).edit().apply {
putLong(PrefKeys.LAST_READ(route), timestampInSecs) putLong(PrefKeys.LAST_READ(route), timestampInSecs)
}.apply() }.apply()
} }
fun loadLastRead(route: String): Long { fun loadLastRead(route: String): Long {
encryptedPreferences.run { encryptedPreferences(currentAccount).run {
return getLong(PrefKeys.LAST_READ(route), 0) return getLong(PrefKeys.LAST_READ(route), 0)
} }
} }
@@ -21,7 +21,7 @@ object NotificationCache {
val scope = CoroutineScope(Job() + Dispatchers.IO) val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch { scope.launch {
LocalPreferences(context).saveLastRead(route, timestampInSecs) LocalPreferences.saveLastRead(route, timestampInSecs)
live.invalidateData() live.invalidateData()
} }
} }
@@ -30,7 +30,7 @@ object NotificationCache {
fun load(route: String, context: Context): Long { fun load(route: String, context: Context): Long {
var lastTime = lastReadByRoute[route] var lastTime = lastReadByRoute[route]
if (lastTime == null) { if (lastTime == null) {
lastTime = LocalPreferences(context).loadLastRead(route) lastTime = LocalPreferences.loadLastRead(route)
lastReadByRoute[route] = lastTime lastReadByRoute[route] = lastTime
} }
return lastTime return lastTime
@@ -14,7 +14,6 @@ import coil.ImageLoader
import coil.decode.GifDecoder import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder import coil.decode.SvgDecoder
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.service.relays.Client
@@ -54,7 +53,7 @@ class MainActivity : FragmentActivity() {
// A surface container using the 'background' color from the theme // A surface container using the 'background' color from the theme
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
val accountStateViewModel: AccountStateViewModel = viewModel { val accountStateViewModel: AccountStateViewModel = viewModel {
AccountStateViewModel(LocalPreferences(applicationContext)) AccountStateViewModel()
} }
AccountScreen(accountStateViewModel, startingPage) AccountScreen(accountStateViewModel, startingPage)
@@ -1,54 +1,81 @@
package com.vitorpamplona.amethyst.ui.navigation package com.vitorpamplona.amethyst.ui.navigation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Icon import androidx.compose.material.Icon
import androidx.compose.material.IconButton import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.ModalBottomSheetState import androidx.compose.material.ModalBottomSheetState
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.TextButton import androidx.compose.material.TextButton
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Logout import androidx.compose.material.icons.filled.Logout
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillNode
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalAutofill
import androidx.compose.ui.platform.LocalAutofillTree
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.RoboHashCache import com.vitorpamplona.amethyst.RoboHashCache
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.ResizeImage import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.note.toShortenHex import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class)
@Composable @Composable
fun AccountSwitchBottomSheet( fun AccountSwitchBottomSheet(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
accountStateViewModel: AccountStateViewModel,
sheetState: ModalBottomSheetState sheetState: ModalBottomSheetState
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
val localPrefs = LocalPreferences(context) val accounts = LocalPreferences.findAllLocalAccounts()
val accounts = localPrefs.findAllLocalAccounts()
val accountState by accountViewModel.accountLiveData.observeAsState() val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return val account = accountState?.account ?: return
@@ -56,9 +83,7 @@ fun AccountSwitchBottomSheet(
val accountUserState by account.userProfile().live().metadata.observeAsState() val accountUserState by account.userProfile().live().metadata.observeAsState()
val accountUser = accountUserState?.user ?: return val accountUser = accountUserState?.user ?: return
LaunchedEffect(key1 = accountUser) { var popupExpanded by remember { mutableStateOf(false) }
localPrefs.saveCurrentAccountMetadata(account)
}
Column { Column {
Row( Row(
@@ -114,9 +139,98 @@ fun AccountSwitchBottomSheet(
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
TextButton(onClick = { coroutineScope.launch { sheetState.hide() } }) { TextButton(onClick = { popupExpanded = true }) {
Text("Add New Account") Text("Add New Account")
} }
} }
} }
if (popupExpanded) {
Dialog(
onDismissRequest = { popupExpanded = false },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxHeight()
.background(MaterialTheme.colors.surface),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
val key = remember { mutableStateOf(TextFieldValue("")) }
var errorMessage by remember { mutableStateOf("") }
var showPassword by remember {
mutableStateOf(false)
}
val autofillNode = AutofillNode(
autofillTypes = listOf(AutofillType.Password),
onFill = { key.value = TextFieldValue(it) }
)
val autofill = LocalAutofill.current
LocalAutofillTree.current += autofillNode
OutlinedTextField(
modifier = Modifier
.onGloballyPositioned { coordinates ->
autofillNode.boundingBox = coordinates.boundsInWindow()
}
.onFocusChanged { focusState ->
autofill?.run {
if (focusState.isFocused) {
requestAutofillForNode(autofillNode)
} else {
cancelAutofillForNode(autofillNode)
}
}
},
value = key.value,
onValueChange = { key.value = it },
keyboardOptions = KeyboardOptions(
autoCorrect = false,
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go
),
placeholder = {
Text(
text = stringResource(R.string.nsec_npub_hex_private_key),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
trailingIcon = {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(
imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
contentDescription = if (showPassword) {
stringResource(R.string.show_password)
} else {
stringResource(
R.string.hide_password
)
}
)
}
},
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
keyboardActions = KeyboardActions(
onGo = {
try {
accountStateViewModel.login(key.value.text)
} catch (e: Exception) {
errorMessage = context.getString(R.string.invalid_key)
}
}
)
)
if (errorMessage.isNotBlank()) {
Text(
text = errorMessage,
color = MaterialTheme.colors.error,
style = MaterialTheme.typography.caption
)
}
}
}
}
}
} }
@@ -17,7 +17,7 @@ import nostr.postr.Persona
import nostr.postr.bechToBytes import nostr.postr.bechToBytes
import java.util.regex.Pattern import java.util.regex.Pattern
class AccountStateViewModel(private val localPreferences: LocalPreferences) : ViewModel() { class AccountStateViewModel() : ViewModel() {
private val _accountContent = MutableStateFlow<AccountState>(AccountState.LoggedOff) private val _accountContent = MutableStateFlow<AccountState>(AccountState.LoggedOff)
val accountContent = _accountContent.asStateFlow() val accountContent = _accountContent.asStateFlow()
@@ -26,7 +26,7 @@ class AccountStateViewModel(private val localPreferences: LocalPreferences) : Vi
// Keeps it in the the UI thread to void blinking the login page. // Keeps it in the the UI thread to void blinking the login page.
// viewModelScope.launch(Dispatchers.IO) { // viewModelScope.launch(Dispatchers.IO) {
localPreferences.loadFromEncryptedStorage()?.let { LocalPreferences.loadFromEncryptedStorage()?.let {
login(it) login(it)
} }
// } // }
@@ -47,18 +47,19 @@ class AccountStateViewModel(private val localPreferences: LocalPreferences) : Vi
Account(Persona(Hex.decode(key))) Account(Persona(Hex.decode(key)))
} }
localPreferences.saveToEncryptedStorage(account) LocalPreferences.saveToEncryptedStorage(account)
login(account) login(account)
} }
fun newKey() { fun newKey() {
val account = Account(Persona()) val account = Account(Persona())
localPreferences.saveToEncryptedStorage(account) LocalPreferences.saveToEncryptedStorage(account)
login(account) login(account)
} }
fun login(account: Account) { fun login(account: Account) {
LocalPreferences.setCurrentAccount(account)
if (account.loggedIn.privKey != null) { if (account.loggedIn.privKey != null) {
_accountContent.update { AccountState.LoggedIn(account) } _accountContent.update { AccountState.LoggedIn(account) }
} else { } else {
@@ -77,7 +78,7 @@ class AccountStateViewModel(private val localPreferences: LocalPreferences) : Vi
private val saveListener: (com.vitorpamplona.amethyst.model.AccountState) -> Unit = { private val saveListener: (com.vitorpamplona.amethyst.model.AccountState) -> Unit = {
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
localPreferences.saveToEncryptedStorage(it.account) LocalPreferences.saveToEncryptedStorage(it.account)
} }
} }
@@ -100,6 +101,6 @@ class AccountStateViewModel(private val localPreferences: LocalPreferences) : Vi
_accountContent.update { AccountState.LoggedOff } _accountContent.update { AccountState.LoggedOff }
localPreferences.clearEncryptedStorage() LocalPreferences.clearEncryptedStorage()
} }
} }
@@ -47,7 +47,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
ModalBottomSheetLayout( ModalBottomSheetLayout(
sheetState = sheetState, sheetState = sheetState,
sheetContent = { sheetContent = {
AccountSwitchBottomSheet(accountViewModel = accountViewModel, sheetState = sheetState) AccountSwitchBottomSheet(accountViewModel = accountViewModel, accountStateViewModel = accountStateViewModel, sheetState = sheetState)
} }
) { ) {
Scaffold( Scaffold(