From b7f8241a086daf0318e4d05ff60123e72be81f68 Mon Sep 17 00:00:00 2001 From: maxmoney21m Date: Sat, 11 Mar 2023 23:13:04 +0800 Subject: [PATCH 01/13] Add account switch bottom sheet and update LocalPrefs --- .../amethyst/LocalPreferences.kt | 164 ++++++++++++------ .../ui/navigation/AccountSwitchBottomSheet.kt | 122 +++++++++++++ .../amethyst/ui/navigation/DrawerContent.kt | 23 ++- .../amethyst/ui/navigation/Routes.kt | 115 ++++++++---- .../ui/screen/loggedIn/ChannelScreen.kt | 7 +- .../amethyst/ui/screen/loggedIn/MainScreen.kt | 56 ++++-- app/src/main/res/drawable/manage_accounts.xml | 7 + 7 files changed, 384 insertions(+), 110 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt create mode 100644 app/src/main/res/drawable/manage_accounts.xml diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 83e0f331a..bbe4e24d2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -11,21 +11,44 @@ import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent import nostr.postr.Persona import nostr.postr.toHex +import nostr.postr.toNpub import java.util.Locale +data class AccountInfo(val npub: String, val current: Boolean, val displayName: String?, val profilePicture: String?) + class LocalPreferences(context: Context) { + + private fun prefKeysForAccount(npub: String) = object { + val NOSTR_PRIVKEY = "$npub/nostr_privkey" + val NOSTR_PUBKEY = "$npub/nostr_pubkey" + val DISPLAY_NAME = "$npub/display_name" + val PROFILE_PICTURE_URL = "$npub/profile_picture" + val FOLLOWING_CHANNELS = "$npub/following_channels" + val HIDDEN_USERS = "$npub/hidden_users" + val RELAYS = "$npub/relays" + val DONT_TRANSLATE_FROM = "$npub/dontTranslateFrom" + val LANGUAGE_PREFS = "$npub/languagePreferences" + val TRANSLATE_TO = "$npub/translateTo" + val ZAP_AMOUNTS = "$npub/zapAmounts" + val LATEST_CONTACT_LIST = "$npub/latestContactList" + val HIDE_DELETE_REQUEST_INFO = "$npub/hideDeleteRequestInfo" +// val LAST_READ: (String) -> String = { route -> "$npub/last_read_route_$route" } + } + private object PrefKeys { - 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 LATEST_CONTACT_LIST = "latestContactList" - const val HIDE_DELETE_REQUEST_INFO = "hideDeleteRequestInfo" + const val CURRENT_ACCOUNT = "currentlyLoggedInAccount" + +// val NOSTR_PRIVKEY = "nostr_privkey" +// val NOSTR_PUBKEY = "nostr_pubkey" +// val FOLLOWING_CHANNELS = "following_channels" +// val HIDDEN_USERS = "hidden_users" +// 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" } } @@ -34,47 +57,88 @@ class LocalPreferences(context: Context) { fun clearEncryptedStorage() { encryptedPreferences.edit().apply { - encryptedPreferences.all.keys.forEach { remove(it) } + encryptedPreferences.all.keys.forEach { + remove(it) + } +// encryptedPreferences.all.keys.filter { +// it.startsWith(npub) +// }.forEach { +// remove(it) +// } }.apply() } + fun findAllLocalAccounts(): List { + encryptedPreferences.apply { + val currentAccount = getString(PrefKeys.CURRENT_ACCOUNT, null) + return encryptedPreferences.all.keys.filter { + it.endsWith("nostr_pubkey") + }.map { + val npub = it.substringBefore("/") + val myPrefs = prefKeysForAccount(npub) + AccountInfo( + npub, + npub == currentAccount, + getString(myPrefs.DISPLAY_NAME, null), + getString(myPrefs.PROFILE_PICTURE_URL, null) + ) + } + } + } + fun saveToEncryptedStorage(account: Account) { + val npub = account.loggedIn.pubKey.toNpub() + val myPrefs = prefKeysForAccount(npub) + encryptedPreferences.edit().apply { - account.loggedIn.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHex()) } - account.loggedIn.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHex()) } - account.followingChannels.let { putStringSet(PrefKeys.FOLLOWING_CHANNELS, it) } - account.hiddenUsers.let { putStringSet(PrefKeys.HIDDEN_USERS, it) } - account.localRelays.let { putString(PrefKeys.RELAYS, gson.toJson(it)) } - account.dontTranslateFrom.let { putStringSet(PrefKeys.DONT_TRANSLATE_FROM, it) } - account.languagePreferences.let { putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(it)) } - account.translateTo.let { putString(PrefKeys.TRANSLATE_TO, it) } - account.zapAmountChoices.let { putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(it)) } - account.backupContactList.let { putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(it)) } - putBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, account.hideDeleteRequestInfo) + 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) { + val myPrefs = prefKeysForAccount(account.loggedIn.pubKey.toNpub()) + + encryptedPreferences.edit().apply { + putString(myPrefs.DISPLAY_NAME, account.userProfile().toBestDisplayName()) + putString(myPrefs.PROFILE_PICTURE_URL, account.userProfile().profilePicture()) }.apply() } fun loadFromEncryptedStorage(): Account? { encryptedPreferences.apply { - val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null) - val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) - val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf() - val hiddenUsers = getStringSet(PrefKeys.HIDDEN_USERS, emptySet()) ?: setOf() + val npub = getString(PrefKeys.CURRENT_ACCOUNT, null) ?: return null + val myPrefs = prefKeysForAccount(npub) + + val pubKey = getString(myPrefs.NOSTR_PUBKEY, null) ?: return null + 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( - getString(PrefKeys.RELAYS, "[]"), + getString(myPrefs.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 dontTranslateFrom = getStringSet(myPrefs.DONT_TRANSLATE_FROM, null) ?: setOf() + val translateTo = getString(myPrefs.TRANSLATE_TO, null) ?: Locale.getDefault().language val zapAmountChoices = gson.fromJson( - getString(PrefKeys.ZAP_AMOUNTS, "[]"), + getString(myPrefs.ZAP_AMOUNTS, "[]"), object : TypeToken>() {}.type ) ?: listOf(500L, 1000L, 5000L) val latestContactList = try { - getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let { + getString(myPrefs.LATEST_CONTACT_LIST, null)?.let { Event.gson.fromJson(it, Event::class.java).getRefinedEvent(true) as ContactListEvent } } catch (e: Throwable) { @@ -83,32 +147,28 @@ class LocalPreferences(context: Context) { } val languagePreferences = try { - getString(PrefKeys.LANGUAGE_PREFS, null)?.let { + getString(myPrefs.LANGUAGE_PREFS, null)?.let { gson.fromJson(it, object : TypeToken>() {}.type) as Map - } ?: mapOf() + } ?: mapOf() } catch (e: Throwable) { e.printStackTrace() - mapOf() + mapOf() } - val hideDeleteRequestInfo = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, false) + val hideDeleteRequestInfo = getBoolean(myPrefs.HIDE_DELETE_REQUEST_INFO, false) - if (pubKey != null) { - return Account( - Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()), - followingChannels, - hiddenUsers, - localRelays, - dontTranslateFrom, - languagePreferences, - translateTo, - zapAmountChoices, - hideDeleteRequestInfo, - latestContactList - ) - } else { - return null - } + return Account( + Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()), + followingChannels, + hiddenUsers, + localRelays, + dontTranslateFrom, + languagePreferences, + translateTo, + zapAmountChoices, + hideDeleteRequestInfo, + latestContactList + ) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt new file mode 100644 index 000000000..f206ac2d0 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt @@ -0,0 +1,122 @@ +package com.vitorpamplona.amethyst.ui.navigation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.ModalBottomSheetState +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Logout +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.RoboHashCache +import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy +import com.vitorpamplona.amethyst.ui.components.ResizeImage +import com.vitorpamplona.amethyst.ui.note.toShortenHex +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun AccountSwitchBottomSheet( + accountViewModel: AccountViewModel, + sheetState: ModalBottomSheetState +) { + val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + val localPrefs = LocalPreferences(context) + val accounts = localPrefs.findAllLocalAccounts() + + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account ?: return + + val accountUserState by account.userProfile().live().metadata.observeAsState() + val accountUser = accountUserState?.user ?: return + + LaunchedEffect(key1 = accountUser) { + localPrefs.saveCurrentAccountMetadata(account) + } + + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Select Account", fontWeight = FontWeight.Bold) + } + accounts.forEach { acc -> + val current = accountUser.pubkeyNpub() == acc.npub + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp, 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AsyncImageProxy( + model = ResizeImage(acc.profilePicture, 64.dp), + placeholder = BitmapPainter(RoboHashCache.get(context, acc.npub)), + fallback = BitmapPainter(RoboHashCache.get(context, acc.npub)), + error = BitmapPainter(RoboHashCache.get(context, acc.npub)), + contentDescription = stringResource(id = R.string.profile_image), + modifier = Modifier + .width(64.dp) + .height(64.dp) + .clip(shape = CircleShape) + ) + Spacer(modifier = Modifier.width(16.dp)) + Column { + acc.displayName?.let { + Text(it) + } + Text(acc.npub.toShortenHex()) + } + Spacer(modifier = Modifier.width(8.dp)) + if (current) { + Text("✓") + } + Spacer(modifier = Modifier.weight(1f)) + IconButton(onClick = { /*TODO*/ }) { + Icon(imageVector = Icons.Default.Logout, "Logout") + } + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = { coroutineScope.launch { sheetState.hide() } }) { + Text("Add New Account") + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index 9ac1cb2c4..8198b7058 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -16,9 +16,11 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme +import androidx.compose.material.ModalBottomSheetState import androidx.compose.material.ScaffoldState import androidx.compose.material.Surface import androidx.compose.material.Text @@ -56,10 +58,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.coroutines.launch +@OptIn(ExperimentalMaterialApi::class) @Composable fun DrawerContent( navController: NavHostController, scaffoldState: ScaffoldState, + sheetState: ModalBottomSheetState, accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel ) { @@ -88,6 +92,7 @@ fun DrawerContent( account.userProfile(), navController, scaffoldState, + sheetState, modifier = Modifier .fillMaxWidth() .weight(1F), @@ -214,15 +219,18 @@ fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffol } } +@OptIn(ExperimentalMaterialApi::class) @Composable fun ListContent( accountUser: User?, navController: NavHostController, scaffoldState: ScaffoldState, + sheetState: ModalBottomSheetState, modifier: Modifier, accountViewModel: AccountStateViewModel, account: Account ) { + val coroutineScope = rememberCoroutineScope() var backupDialogOpen by remember { mutableStateOf(false) } Column(modifier = modifier.fillMaxHeight()) { @@ -260,11 +268,18 @@ fun ListContent( Spacer(modifier = Modifier.weight(1f)) IconRow( - stringResource(R.string.log_out), - R.drawable.ic_logout, - MaterialTheme.colors.onBackground, - onClick = { accountViewModel.logOff() } + title = "Accounts", + icon = R.drawable.manage_accounts, + tint = MaterialTheme.colors.onBackground, + onClick = { coroutineScope.launch { sheetState.show() } } ) + +// IconRow( +// title = stringResource(R.string.log_out), +// icon = R.drawable.ic_logout, +// tint = MaterialTheme.colors.onBackground, +// onClick = { accountViewModel.logOff() } +// ) } if (backupDialogOpen) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index 66bed57fa..8ca43e4e1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -36,62 +36,95 @@ sealed class Route( val buildScreen: (AccountViewModel, AccountStateViewModel, NavController) -> @Composable (NavBackStackEntry) -> Unit ) { object Home : Route( - "Home", - R.drawable.ic_home, - hasNewItems = { acc, cache, ctx -> homeHasNewItems(acc, cache, ctx) }, - buildScreen = { acc, accSt, nav -> { _ -> HomeScreen(acc, nav) } } + route = "Home", + icon = R.drawable.ic_home, + hasNewItems = { accountViewModel, cache, context -> + homeHasNewItems(accountViewModel, cache, context) + }, + buildScreen = { accountViewModel, _, navController -> + { HomeScreen(accountViewModel, navController) } + } ) + object Search : Route( - "Search", - R.drawable.ic_globe, - buildScreen = { acc, accSt, nav -> { _ -> SearchScreen(acc, nav) } } + route = "Search", + icon = R.drawable.ic_globe, + buildScreen = { accountViewModel, _, navController -> + { SearchScreen(accountViewModel, navController) } + } ) + object Notification : Route( - "Notification", - R.drawable.ic_notifications, - hasNewItems = { acc, cache, ctx -> notificationHasNewItems(acc, cache, ctx) }, - buildScreen = { acc, accSt, nav -> { _ -> NotificationScreen(acc, nav) } } + route = "Notification", + icon = R.drawable.ic_notifications, + hasNewItems = { accountViewModel, cache, context -> + notificationHasNewItems(accountViewModel, cache, context) + }, + buildScreen = { accountViewModel, _, navController -> + { NotificationScreen(accountViewModel, navController) } + } ) object Message : Route( - "Message", - R.drawable.ic_dm, - hasNewItems = { acc, cache, ctx -> messagesHasNewItems(acc, cache, ctx) }, - buildScreen = { acc, accSt, nav -> { _ -> ChatroomListScreen(acc, nav) } } + route = "Message", + icon = R.drawable.ic_dm, + hasNewItems = { accountViewModel, cache, context -> + messagesHasNewItems(accountViewModel, cache, context) + }, + buildScreen = { accountViewModel, _, navController -> + { ChatroomListScreen(accountViewModel, navController) } + } ) object Filters : Route( - "Filters", - R.drawable.ic_security, - buildScreen = { acc, accSt, nav -> { _ -> FiltersScreen(acc, nav) } } + route = "Filters", + icon = R.drawable.ic_security, + buildScreen = { accountViewModel, _, navController -> + { FiltersScreen(accountViewModel, navController) } + } ) object Profile : Route( - "User/{id}", - R.drawable.ic_profile, + route = "User/{id}", + icon = R.drawable.ic_profile, arguments = listOf(navArgument("id") { type = NavType.StringType }), - buildScreen = { acc, accSt, nav -> { ProfileScreen(it.arguments?.getString("id"), acc, nav) } } + buildScreen = { accountViewModel, _, navController -> + { ProfileScreen(it.arguments?.getString("id"), accountViewModel, navController) } + } ) object Note : Route( - "Note/{id}", - R.drawable.ic_moments, + route = "Note/{id}", + icon = R.drawable.ic_moments, arguments = listOf(navArgument("id") { type = NavType.StringType }), - buildScreen = { acc, accSt, nav -> { ThreadScreen(it.arguments?.getString("id"), acc, nav) } } + buildScreen = { accountViewModel, _, navController -> + { ThreadScreen(it.arguments?.getString("id"), accountViewModel, navController) } + } ) object Room : Route( - "Room/{id}", - R.drawable.ic_moments, + route = "Room/{id}", + icon = R.drawable.ic_moments, arguments = listOf(navArgument("id") { type = NavType.StringType }), - buildScreen = { acc, accSt, nav -> { ChatroomScreen(it.arguments?.getString("id"), acc, nav) } } + buildScreen = { accountViewModel, _, navController -> + { ChatroomScreen(it.arguments?.getString("id"), accountViewModel, navController) } + } ) object Channel : Route( - "Channel/{id}", - R.drawable.ic_moments, + route = "Channel/{id}", + icon = R.drawable.ic_moments, arguments = listOf(navArgument("id") { type = NavType.StringType }), - buildScreen = { acc, accSt, nav -> { ChannelScreen(it.arguments?.getString("id"), acc, accSt, nav) } } + buildScreen = { accountViewModel, accountStateViewModel, navController -> + { + ChannelScreen( + it.arguments?.getString("id"), + accountViewModel, + accountStateViewModel, + navController + ) + } + } ) } @@ -124,18 +157,32 @@ private fun homeHasNewItems(account: Account, cache: NotificationCache, context: HomeNewThreadFeedFilter.account = account - return (HomeNewThreadFeedFilter.feed().firstOrNull { it.createdAt() != null }?.createdAt() ?: 0) > lastTime + return ( + HomeNewThreadFeedFilter.feed().firstOrNull { it.createdAt() != null }?.createdAt() + ?: 0 + ) > lastTime } -private fun notificationHasNewItems(account: Account, cache: NotificationCache, context: Context): Boolean { +private fun notificationHasNewItems( + account: Account, + cache: NotificationCache, + context: Context +): Boolean { val lastTime = cache.load("Notification", context) NotificationFeedFilter.account = account - return (NotificationFeedFilter.feed().firstOrNull { it.createdAt() != null }?.createdAt() ?: 0) > lastTime + return ( + NotificationFeedFilter.feed().firstOrNull { it.createdAt() != null }?.createdAt() + ?: 0 + ) > lastTime } -private fun messagesHasNewItems(account: Account, cache: NotificationCache, context: Context): Boolean { +private fun messagesHasNewItems( + account: Account, + cache: NotificationCache, + context: Context +): Boolean { ChatroomListKnownFeedFilter.account = account val note = ChatroomListKnownFeedFilter.feed().firstOrNull { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt index 71fa2ee6c..496666f7e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt @@ -78,7 +78,12 @@ import com.vitorpamplona.amethyst.ui.screen.ChatroomFeedView import com.vitorpamplona.amethyst.ui.screen.NostrChannelFeedViewModel @Composable -fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel, navController: NavController) { +fun ChannelScreen( + channelId: String?, + accountViewModel: AccountViewModel, + accountStateViewModel: AccountStateViewModel, + navController: NavController +) { val accountState by accountViewModel.accountLiveData.observeAsState() val account = accountState?.account diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt index fa698b275..e296fec90 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt @@ -7,9 +7,13 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material.DrawerValue +import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme +import androidx.compose.material.ModalBottomSheetLayout +import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.Scaffold import androidx.compose.material.rememberDrawerState +import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -19,6 +23,7 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController import com.vitorpamplona.amethyst.buttons.NewChannelButton import com.vitorpamplona.amethyst.buttons.NewNoteButton +import com.vitorpamplona.amethyst.ui.navigation.AccountSwitchBottomSheet import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.AppNavigation import com.vitorpamplona.amethyst.ui.navigation.AppTopBar @@ -28,31 +33,44 @@ import com.vitorpamplona.amethyst.ui.navigation.currentRoute import com.vitorpamplona.amethyst.ui.screen.AccountState import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +@OptIn(ExperimentalMaterialApi::class) @Composable fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel, startingPage: String? = null) { val navController = rememberNavController() val scaffoldState = rememberScaffoldState(rememberDrawerState(DrawerValue.Closed)) + val sheetState = rememberModalBottomSheetState( + initialValue = ModalBottomSheetValue.Hidden, + confirmValueChange = { it != ModalBottomSheetValue.HalfExpanded }, + skipHalfExpanded = true + ) - Scaffold( - modifier = Modifier - .background(MaterialTheme.colors.primaryVariant) - .statusBarsPadding(), - bottomBar = { - AppBottomBar(navController, accountViewModel) - }, - topBar = { - AppTopBar(navController, scaffoldState, accountViewModel) - }, - drawerContent = { - DrawerContent(navController, scaffoldState, accountViewModel, accountStateViewModel) - }, - floatingActionButton = { - FloatingButton(navController, accountStateViewModel) - }, - scaffoldState = scaffoldState + ModalBottomSheetLayout( + sheetState = sheetState, + sheetContent = { + AccountSwitchBottomSheet(accountViewModel = accountViewModel, sheetState = sheetState) + } ) { - Column(modifier = Modifier.padding(bottom = it.calculateBottomPadding())) { - AppNavigation(navController, accountViewModel, accountStateViewModel, startingPage) + Scaffold( + modifier = Modifier + .background(MaterialTheme.colors.primaryVariant) + .statusBarsPadding(), + bottomBar = { + AppBottomBar(navController, accountViewModel) + }, + topBar = { + AppTopBar(navController, scaffoldState, accountViewModel) + }, + drawerContent = { + DrawerContent(navController, scaffoldState, sheetState, accountViewModel, accountStateViewModel) + }, + floatingActionButton = { + FloatingButton(navController, accountStateViewModel) + }, + scaffoldState = scaffoldState + ) { + Column(modifier = Modifier.padding(bottom = it.calculateBottomPadding())) { + AppNavigation(navController, accountViewModel, accountStateViewModel, startingPage) + } } } } diff --git a/app/src/main/res/drawable/manage_accounts.xml b/app/src/main/res/drawable/manage_accounts.xml new file mode 100644 index 000000000..734c70559 --- /dev/null +++ b/app/src/main/res/drawable/manage_accounts.xml @@ -0,0 +1,7 @@ + + + + + From 3a2403b3445cbf1006261b8a6bb6c369109ccb4d Mon Sep 17 00:00:00 2001 From: maxmoney21m Date: Sun, 12 Mar 2023 02:18:43 +0800 Subject: [PATCH 02/13] Use multiple preference files for different accounts --- .idea/deploymentTargetDropDown.xml | 17 ++ app/src/main/AndroidManifest.xml | 1 + .../com/vitorpamplona/amethyst/Amethyst.kt | 15 ++ .../amethyst/EncryptedStorage.kt | 8 +- .../amethyst/LocalPreferences.kt | 197 ++++++++++-------- .../amethyst/NotificationCache.kt | 4 +- .../vitorpamplona/amethyst/ui/MainActivity.kt | 3 +- .../ui/navigation/AccountSwitchBottomSheet.kt | 132 +++++++++++- .../ui/screen/AccountStateViewModel.kt | 15 +- .../amethyst/ui/screen/loggedIn/MainScreen.kt | 2 +- 10 files changed, 278 insertions(+), 116 deletions(-) create mode 100644 .idea/deploymentTargetDropDown.xml create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml new file mode 100644 index 000000000..0c0237e9c --- /dev/null +++ b/.idea/deploymentTargetDropDown.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 641da01bb..5a3417398 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,6 +13,7 @@ String = { route -> "last_read_route_$route" } +} - private fun prefKeysForAccount(npub: String) = object { - val NOSTR_PRIVKEY = "$npub/nostr_privkey" - val NOSTR_PUBKEY = "$npub/nostr_pubkey" - val DISPLAY_NAME = "$npub/display_name" - val PROFILE_PICTURE_URL = "$npub/profile_picture" - val FOLLOWING_CHANNELS = "$npub/following_channels" - val HIDDEN_USERS = "$npub/hidden_users" - val RELAYS = "$npub/relays" - val DONT_TRANSLATE_FROM = "$npub/dontTranslateFrom" - val LANGUAGE_PREFS = "$npub/languagePreferences" - val TRANSLATE_TO = "$npub/translateTo" - val ZAP_AMOUNTS = "$npub/zapAmounts" - val LATEST_CONTACT_LIST = "$npub/latestContactList" - val HIDE_DELETE_REQUEST_INFO = "$npub/hideDeleteRequestInfo" -// val LAST_READ: (String) -> String = { route -> "$npub/last_read_route_$route" } +private val gson = GsonBuilder().create() + +object LocalPreferences { + private var currentAccount: String? + get() = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null) + set(npub) { + val prefs = encryptedPreferences() + prefs.edit().apply { + putString(PrefKeys.CURRENT_ACCOUNT, npub) + }.apply() + } + + private val savedAccounts: Set + get() = encryptedPreferences().getStringSet(PrefKeys.SAVED_ACCOUNTS, null) ?: setOf() + + 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 { - const val CURRENT_ACCOUNT = "currentlyLoggedInAccount" - -// val NOSTR_PRIVKEY = "nostr_privkey" -// val NOSTR_PUBKEY = "nostr_pubkey" -// val FOLLOWING_CHANNELS = "following_channels" -// val HIDDEN_USERS = "hidden_users" -// 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 fun removeAccount(npub: String) { + val accounts = savedAccounts.toMutableSet() + accounts.remove(npub) + val prefs = encryptedPreferences() + prefs.edit().apply { + putStringSet(PrefKeys.SAVED_ACCOUNTS, accounts) + }.apply() } - private val encryptedPreferences = EncryptedStorage.preferences(context) - private val gson = GsonBuilder().create() + private fun encryptedPreferences(npub: String? = null): SharedPreferences { + 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() { - encryptedPreferences.edit().apply { - encryptedPreferences.all.keys.forEach { + fun clearEncryptedStorage(npub: String? = null) { + val encPrefs = encryptedPreferences(npub) + encPrefs.edit().apply { + encPrefs.all.keys.forEach { remove(it) } // encryptedPreferences.all.keys.filter { @@ -69,76 +94,64 @@ class LocalPreferences(context: Context) { } fun findAllLocalAccounts(): List { - encryptedPreferences.apply { - val currentAccount = getString(PrefKeys.CURRENT_ACCOUNT, null) - return encryptedPreferences.all.keys.filter { - it.endsWith("nostr_pubkey") - }.map { - val npub = it.substringBefore("/") - val myPrefs = prefKeysForAccount(npub) - AccountInfo( - npub, - npub == currentAccount, - getString(myPrefs.DISPLAY_NAME, null), - getString(myPrefs.PROFILE_PICTURE_URL, null) - ) - } + return savedAccounts.map { npub -> + val prefs = encryptedPreferences(npub) + + AccountInfo( + npub = npub, + current = npub == currentAccount, + displayName = prefs.getString(PrefKeys.DISPLAY_NAME, null), + profilePicture = prefs.getString(PrefKeys.PROFILE_PICTURE_URL, null) + ) } } - fun saveToEncryptedStorage(account: Account) { - val npub = account.loggedIn.pubKey.toNpub() - val myPrefs = prefKeysForAccount(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 setCurrentAccount(account: Account) { + val npub = account.userProfile().pubkeyNpub() + currentAccount = npub + addAccount(npub) } - fun saveCurrentAccountMetadata(account: Account) { - val myPrefs = prefKeysForAccount(account.loggedIn.pubKey.toNpub()) - - encryptedPreferences.edit().apply { - putString(myPrefs.DISPLAY_NAME, account.userProfile().toBestDisplayName()) - putString(myPrefs.PROFILE_PICTURE_URL, account.userProfile().profilePicture()) + 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.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() } fun loadFromEncryptedStorage(): Account? { - encryptedPreferences.apply { - val npub = getString(PrefKeys.CURRENT_ACCOUNT, null) ?: return null - val myPrefs = prefKeysForAccount(npub) - - val pubKey = getString(myPrefs.NOSTR_PUBKEY, null) ?: return null - val privKey = getString(myPrefs.NOSTR_PRIVKEY, null) - val followingChannels = getStringSet(myPrefs.FOLLOWING_CHANNELS, null) ?: setOf() - val hiddenUsers = getStringSet(myPrefs.HIDDEN_USERS, emptySet()) ?: setOf() + encryptedPreferences(currentAccount).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(myPrefs.RELAYS, "[]"), + getString(PrefKeys.RELAYS, "[]"), object : TypeToken>() {}.type ) ?: setOf() - val dontTranslateFrom = getStringSet(myPrefs.DONT_TRANSLATE_FROM, null) ?: setOf() - val translateTo = getString(myPrefs.TRANSLATE_TO, null) ?: Locale.getDefault().language + val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf() + val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language val zapAmountChoices = gson.fromJson( - getString(myPrefs.ZAP_AMOUNTS, "[]"), + getString(PrefKeys.ZAP_AMOUNTS, "[]"), object : TypeToken>() {}.type ) ?: listOf(500L, 1000L, 5000L) 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 } } catch (e: Throwable) { @@ -147,7 +160,7 @@ class LocalPreferences(context: Context) { } val languagePreferences = try { - getString(myPrefs.LANGUAGE_PREFS, null)?.let { + getString(PrefKeys.LANGUAGE_PREFS, null)?.let { gson.fromJson(it, object : TypeToken>() {}.type) as Map } ?: mapOf() } catch (e: Throwable) { @@ -155,7 +168,7 @@ class LocalPreferences(context: Context) { mapOf() } - val hideDeleteRequestInfo = getBoolean(myPrefs.HIDE_DELETE_REQUEST_INFO, false) + val hideDeleteRequestInfo = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, false) return Account( Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()), @@ -173,13 +186,13 @@ class LocalPreferences(context: Context) { } fun saveLastRead(route: String, timestampInSecs: Long) { - encryptedPreferences.edit().apply { + encryptedPreferences(currentAccount).edit().apply { putLong(PrefKeys.LAST_READ(route), timestampInSecs) }.apply() } fun loadLastRead(route: String): Long { - encryptedPreferences.run { + encryptedPreferences(currentAccount).run { return getLong(PrefKeys.LAST_READ(route), 0) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/NotificationCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/NotificationCache.kt index 0173fe3bd..891303243 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/NotificationCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/NotificationCache.kt @@ -21,7 +21,7 @@ object NotificationCache { val scope = CoroutineScope(Job() + Dispatchers.IO) scope.launch { - LocalPreferences(context).saveLastRead(route, timestampInSecs) + LocalPreferences.saveLastRead(route, timestampInSecs) live.invalidateData() } } @@ -30,7 +30,7 @@ object NotificationCache { fun load(route: String, context: Context): Long { var lastTime = lastReadByRoute[route] if (lastTime == null) { - lastTime = LocalPreferences(context).loadLastRead(route) + lastTime = LocalPreferences.loadLastRead(route) lastReadByRoute[route] = lastTime } return lastTime diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index cdb3aab35..a11394367 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -14,7 +14,6 @@ import coil.ImageLoader import coil.decode.GifDecoder import coil.decode.ImageDecoderDecoder import coil.decode.SvgDecoder -import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.ServiceManager import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.service.relays.Client @@ -54,7 +53,7 @@ class MainActivity : FragmentActivity() { // A surface container using the 'background' color from the theme Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { val accountStateViewModel: AccountStateViewModel = viewModel { - AccountStateViewModel(LocalPreferences(applicationContext)) + AccountStateViewModel() } AccountScreen(accountStateViewModel, startingPage) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt index f206ac2d0..87685baee 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt @@ -1,54 +1,81 @@ package com.vitorpamplona.amethyst.ui.navigation +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width 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.Icon import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme import androidx.compose.material.ModalBottomSheetState +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.material.icons.Icons 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.LaunchedEffect import androidx.compose.runtime.getValue 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.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi 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.focus.onFocusChanged 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.res.stringResource 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.window.Dialog +import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.RoboHashCache import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy import com.vitorpamplona.amethyst.ui.components.ResizeImage import com.vitorpamplona.amethyst.ui.note.toShortenHex +import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterialApi::class) +@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) @Composable fun AccountSwitchBottomSheet( accountViewModel: AccountViewModel, + accountStateViewModel: AccountStateViewModel, sheetState: ModalBottomSheetState ) { val coroutineScope = rememberCoroutineScope() val context = LocalContext.current - val localPrefs = LocalPreferences(context) - val accounts = localPrefs.findAllLocalAccounts() + val accounts = LocalPreferences.findAllLocalAccounts() val accountState by accountViewModel.accountLiveData.observeAsState() val account = accountState?.account ?: return @@ -56,9 +83,7 @@ fun AccountSwitchBottomSheet( val accountUserState by account.userProfile().live().metadata.observeAsState() val accountUser = accountUserState?.user ?: return - LaunchedEffect(key1 = accountUser) { - localPrefs.saveCurrentAccountMetadata(account) - } + var popupExpanded by remember { mutableStateOf(false) } Column { Row( @@ -114,9 +139,98 @@ fun AccountSwitchBottomSheet( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { - TextButton(onClick = { coroutineScope.launch { sheetState.hide() } }) { + TextButton(onClick = { popupExpanded = true }) { 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 + ) + } + } + } + } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index e57a4ae1b..3321b42df 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt @@ -17,7 +17,7 @@ import nostr.postr.Persona import nostr.postr.bechToBytes import java.util.regex.Pattern -class AccountStateViewModel(private val localPreferences: LocalPreferences) : ViewModel() { +class AccountStateViewModel() : ViewModel() { private val _accountContent = MutableStateFlow(AccountState.LoggedOff) 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. // viewModelScope.launch(Dispatchers.IO) { - localPreferences.loadFromEncryptedStorage()?.let { + LocalPreferences.loadFromEncryptedStorage()?.let { login(it) } // } @@ -47,18 +47,19 @@ class AccountStateViewModel(private val localPreferences: LocalPreferences) : Vi Account(Persona(Hex.decode(key))) } - localPreferences.saveToEncryptedStorage(account) - + LocalPreferences.saveToEncryptedStorage(account) login(account) } fun newKey() { val account = Account(Persona()) - localPreferences.saveToEncryptedStorage(account) + LocalPreferences.saveToEncryptedStorage(account) login(account) } fun login(account: Account) { + LocalPreferences.setCurrentAccount(account) + if (account.loggedIn.privKey != null) { _accountContent.update { AccountState.LoggedIn(account) } } else { @@ -77,7 +78,7 @@ class AccountStateViewModel(private val localPreferences: LocalPreferences) : Vi private val saveListener: (com.vitorpamplona.amethyst.model.AccountState) -> Unit = { 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 } - localPreferences.clearEncryptedStorage() + LocalPreferences.clearEncryptedStorage() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt index e296fec90..a1cb6f101 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt @@ -47,7 +47,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun ModalBottomSheetLayout( sheetState = sheetState, sheetContent = { - AccountSwitchBottomSheet(accountViewModel = accountViewModel, sheetState = sheetState) + AccountSwitchBottomSheet(accountViewModel = accountViewModel, accountStateViewModel = accountStateViewModel, sheetState = sheetState) } ) { Scaffold( From 9561261bf4c3ea6c320ee8585c4c4a2efe6cc948 Mon Sep 17 00:00:00 2001 From: maxmoney21m Date: Sun, 12 Mar 2023 02:19:56 +0800 Subject: [PATCH 03/13] Add note --- app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 9705b5829..5a9b72878 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -14,6 +14,7 @@ import nostr.postr.Persona import nostr.postr.toHex import java.util.Locale +// MUST BE SET TO FALSE FOR PRODUCTION!!!!! const val DEBUG_PLAINTEXT_PREFERENCES = true data class AccountInfo(val npub: String, val current: Boolean, val displayName: String?, val profilePicture: String?) From b40bde10a0714ee354f13d602ee2304591baca11 Mon Sep 17 00:00:00 2001 From: maxmoney21m Date: Sun, 12 Mar 2023 02:42:50 +0800 Subject: [PATCH 04/13] Add account, switch account, and logout flows --- .../amethyst/LocalPreferences.kt | 28 +++-- .../ui/navigation/AccountSwitchBottomSheet.kt | 106 ++++++++++++++---- .../ui/screen/AccountStateViewModel.kt | 17 ++- 3 files changed, 116 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 5a9b72878..78840dae1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -80,18 +80,27 @@ object LocalPreferences { } } - fun clearEncryptedStorage(npub: String? = null) { - val encPrefs = encryptedPreferences(npub) - encPrefs.edit().apply { - encPrefs.all.keys.forEach { - remove(it) - } + fun clearEncryptedStorage(npub: String) { + val userPrefs = encryptedPreferences(npub) + userPrefs.edit().clear().apply() + removeAccount(npub) + + if (savedAccounts.isEmpty()) { + val appPrefs = encryptedPreferences() + appPrefs.edit().clear().apply() + } else if (currentAccount == npub) { + currentAccount = savedAccounts.elementAt(0) + } +// encPrefs.edit().apply { +// encPrefs.all.keys.forEach { +// remove(it) +// } // encryptedPreferences.all.keys.filter { // it.startsWith(npub) // }.forEach { // remove(it) // } - }.apply() +// }.apply() } fun findAllLocalAccounts(): List { @@ -132,6 +141,11 @@ object LocalPreferences { }.apply() } + fun login(account: Account) { + setCurrentAccount(account) + saveToEncryptedStorage(account) + } + fun loadFromEncryptedStorage(): Account? { encryptedPreferences(currentAccount).apply { val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt index 87685baee..affca2daf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt @@ -1,6 +1,7 @@ package com.vitorpamplona.amethyst.ui.navigation import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -12,8 +13,12 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon import androidx.compose.material.IconButton @@ -48,13 +53,18 @@ import androidx.compose.ui.platform.LocalAutofill import androidx.compose.ui.platform.LocalAutofillTree import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle 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.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.LocalPreferences @@ -104,30 +114,39 @@ fun AccountSwitchBottomSheet( .padding(32.dp, 16.dp), verticalAlignment = Alignment.CenterVertically ) { - AsyncImageProxy( - model = ResizeImage(acc.profilePicture, 64.dp), - placeholder = BitmapPainter(RoboHashCache.get(context, acc.npub)), - fallback = BitmapPainter(RoboHashCache.get(context, acc.npub)), - error = BitmapPainter(RoboHashCache.get(context, acc.npub)), - contentDescription = stringResource(id = R.string.profile_image), - modifier = Modifier - .width(64.dp) - .height(64.dp) - .clip(shape = CircleShape) - ) - Spacer(modifier = Modifier.width(16.dp)) - Column { - acc.displayName?.let { - Text(it) + Row( + modifier = Modifier.clickable { + accountStateViewModel.login(acc.npub) } - Text(acc.npub.toShortenHex()) + ) { + AsyncImageProxy( + model = ResizeImage(acc.profilePicture, 64.dp), + placeholder = BitmapPainter(RoboHashCache.get(context, acc.npub)), + fallback = BitmapPainter(RoboHashCache.get(context, acc.npub)), + error = BitmapPainter(RoboHashCache.get(context, acc.npub)), + contentDescription = stringResource(id = R.string.profile_image), + modifier = Modifier + .width(64.dp) + .height(64.dp) + .clip(shape = CircleShape) + ) + Spacer(modifier = Modifier.width(16.dp)) + Column { + acc.displayName?.let { + Text(it) + } + Text(acc.npub.toShortenHex()) + } + Spacer(modifier = Modifier.width(8.dp)) + if (current) { + Text("✓") + } + Spacer(modifier = Modifier.weight(1f)) } - Spacer(modifier = Modifier.width(8.dp)) - if (current) { - Text("✓") - } - Spacer(modifier = Modifier.weight(1f)) - IconButton(onClick = { /*TODO*/ }) { + + IconButton( + onClick = { accountStateViewModel.logOff(acc.npub) } + ) { Icon(imageVector = Icons.Default.Logout, "Logout") } } @@ -229,6 +248,49 @@ fun AccountSwitchBottomSheet( style = MaterialTheme.typography.caption ) } + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = { + if (key.value.text.isBlank()) { + errorMessage = context.getString(R.string.key_is_required) + } + try { + accountStateViewModel.login(key.value.text) + } catch (e: Exception) { + errorMessage = context.getString(R.string.invalid_key) + } + }, + shape = RoundedCornerShape(35.dp), + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + colors = ButtonDefaults + .buttonColors( + backgroundColor = MaterialTheme.colors.primary + ) + ) { + Text(text = stringResource(R.string.login)) + } + + Spacer(modifier = Modifier.height(16.dp)) + + ClickableText( + text = AnnotatedString(stringResource(R.string.generate_a_new_key)), + modifier = Modifier + .padding(20.dp) + .fillMaxWidth(), + onClick = { + accountStateViewModel.newKey() + }, + style = TextStyle( + fontSize = 14.sp, + textDecoration = TextDecoration.Underline, + color = MaterialTheme.colors.primary, + textAlign = TextAlign.Center + ) + ) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index 3321b42df..707a877aa 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt @@ -26,10 +26,14 @@ class AccountStateViewModel() : ViewModel() { // Keeps it in the the UI thread to void blinking the login page. // viewModelScope.launch(Dispatchers.IO) { + tryLoginExistingAccount() + // } + } + + private fun tryLoginExistingAccount() { LocalPreferences.loadFromEncryptedStorage()?.let { login(it) } - // } } fun login(key: String) { @@ -47,18 +51,18 @@ class AccountStateViewModel() : ViewModel() { Account(Persona(Hex.decode(key))) } - LocalPreferences.saveToEncryptedStorage(account) + LocalPreferences.login(account) login(account) } fun newKey() { val account = Account(Persona()) - LocalPreferences.saveToEncryptedStorage(account) + LocalPreferences.login(account) login(account) } fun login(account: Account) { - LocalPreferences.setCurrentAccount(account) + LocalPreferences.login(account) if (account.loggedIn.privKey != null) { _accountContent.update { AccountState.LoggedIn(account) } @@ -82,7 +86,7 @@ class AccountStateViewModel() : ViewModel() { } } - fun logOff() { + fun logOff(npub: String) { val state = accountContent.value when (state) { @@ -101,6 +105,7 @@ class AccountStateViewModel() : ViewModel() { _accountContent.update { AccountState.LoggedOff } - LocalPreferences.clearEncryptedStorage() + LocalPreferences.clearEncryptedStorage(npub) + tryLoginExistingAccount() } } From e472a494a1bd0d5fc0eadf9e10904c8e6c92803b Mon Sep 17 00:00:00 2001 From: maxmoney21m Date: Sun, 12 Mar 2023 11:59:16 +0800 Subject: [PATCH 05/13] Re-use existing LoginPage component in account switcher --- .../ui/navigation/AccountSwitchBottomSheet.kt | 181 +++--------------- .../amethyst/ui/screen/AccountScreen.kt | 3 +- .../amethyst/ui/screen/loggedIn/MainScreen.kt | 2 +- .../ui/screen/loggedOff/LoginScreen.kt | 86 +++++---- 4 files changed, 75 insertions(+), 197 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt index affca2daf..d090c46b5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt @@ -1,70 +1,42 @@ package com.vitorpamplona.amethyst.ui.navigation -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.ClickableText -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme -import androidx.compose.material.ModalBottomSheetState -import androidx.compose.material.OutlinedTextField import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.TextButton +import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack 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.getValue 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.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi 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.focus.onFocusChanged +import androidx.compose.ui.graphics.Color 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.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.TextStyle 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.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.LocalPreferences @@ -75,15 +47,13 @@ import com.vitorpamplona.amethyst.ui.components.ResizeImage 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.loggedOff.LoginPage -@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) @Composable fun AccountSwitchBottomSheet( accountViewModel: AccountViewModel, - accountStateViewModel: AccountStateViewModel, - sheetState: ModalBottomSheetState + accountStateViewModel: AccountStateViewModel ) { - val coroutineScope = rememberCoroutineScope() val context = LocalContext.current val accounts = LocalPreferences.findAllLocalAccounts() @@ -117,7 +87,8 @@ fun AccountSwitchBottomSheet( Row( modifier = Modifier.clickable { accountStateViewModel.login(acc.npub) - } + }, + verticalAlignment = Alignment.CenterVertically ) { AsyncImageProxy( model = ResizeImage(acc.profilePicture, 64.dp), @@ -141,13 +112,18 @@ fun AccountSwitchBottomSheet( if (current) { Text("✓") } - Spacer(modifier = Modifier.weight(1f)) } + Spacer(modifier = Modifier.weight(1f)) + IconButton( onClick = { accountStateViewModel.logOff(acc.npub) } ) { - Icon(imageVector = Icons.Default.Logout, "Logout") + Icon( + imageVector = Icons.Default.Logout, + contentDescription = "Logout", + tint = MaterialTheme.colors.onSurface + ) } } } @@ -170,126 +146,21 @@ fun AccountSwitchBottomSheet( 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 }) { + Box { + LoginPage(accountStateViewModel, isFirstLogin = false) + TopAppBar( + title = { Text(text = "Add New Account") }, + navigationIcon = { + IconButton(onClick = { popupExpanded = false }) { 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 - ) - } + imageVector = Icons.Default.ArrowBack, + contentDescription = "Back", + tint = MaterialTheme.colors.onSurface ) } }, - 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 - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - - Button( - onClick = { - if (key.value.text.isBlank()) { - errorMessage = context.getString(R.string.key_is_required) - } - try { - accountStateViewModel.login(key.value.text) - } catch (e: Exception) { - errorMessage = context.getString(R.string.invalid_key) - } - }, - shape = RoundedCornerShape(35.dp), - modifier = Modifier - .fillMaxWidth() - .height(50.dp), - colors = ButtonDefaults - .buttonColors( - backgroundColor = MaterialTheme.colors.primary - ) - ) { - Text(text = stringResource(R.string.login)) - } - - Spacer(modifier = Modifier.height(16.dp)) - - ClickableText( - text = AnnotatedString(stringResource(R.string.generate_a_new_key)), - modifier = Modifier - .padding(20.dp) - .fillMaxWidth(), - onClick = { - accountStateViewModel.newKey() - }, - style = TextStyle( - fontSize = 14.sp, - textDecoration = TextDecoration.Underline, - color = MaterialTheme.colors.primary, - textAlign = TextAlign.Center - ) + backgroundColor = Color.Transparent, + elevation = 0.dp ) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt index d82d6a353..f96914f78 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.MainScreen +import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage @Composable fun AccountScreen(accountStateViewModel: AccountStateViewModel, startingPage: String?) { @@ -17,7 +18,7 @@ fun AccountScreen(accountStateViewModel: AccountStateViewModel, startingPage: St Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state -> when (state) { is AccountState.LoggedOff -> { - LoginPage(accountStateViewModel) + LoginPage(accountStateViewModel, isFirstLogin = true) } is AccountState.LoggedIn -> { MainScreen(AccountViewModel(state.account), accountStateViewModel, startingPage) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt index a1cb6f101..90fac6a13 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt @@ -47,7 +47,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun ModalBottomSheetLayout( sheetState = sheetState, sheetContent = { - AccountSwitchBottomSheet(accountViewModel = accountViewModel, accountStateViewModel = accountStateViewModel, sheetState = sheetState) + AccountSwitchBottomSheet(accountViewModel = accountViewModel, accountStateViewModel = accountStateViewModel) } ) { Scaffold( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt index ffa3c8ce4..d78673f7f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt @@ -1,4 +1,4 @@ -package com.vitorpamplona.amethyst.ui.screen +package com.vitorpamplona.amethyst.ui.screen.loggedOff import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* @@ -36,14 +36,18 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import java.util.* @OptIn(ExperimentalComposeUiApi::class) @Composable -fun LoginPage(accountViewModel: AccountStateViewModel) { +fun LoginPage( + accountViewModel: AccountStateViewModel, + isFirstLogin: Boolean +) { val key = remember { mutableStateOf(TextFieldValue("")) } var errorMessage by remember { mutableStateOf("") } - val acceptedTerms = remember { mutableStateOf(false) } + val acceptedTerms = remember { mutableStateOf(!isFirstLogin) } var termsAcceptanceIsRequired by remember { mutableStateOf("") } val uri = LocalUriHandler.current val context = LocalContext.current @@ -147,48 +151,50 @@ fun LoginPage(accountViewModel: AccountStateViewModel) { Spacer(modifier = Modifier.height(20.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = acceptedTerms.value, - onCheckedChange = { acceptedTerms.value = it } - ) + if (isFirstLogin) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = acceptedTerms.value, + onCheckedChange = { acceptedTerms.value = it } + ) - val regularText = - SpanStyle(color = MaterialTheme.colors.onBackground) + val regularText = + SpanStyle(color = MaterialTheme.colors.onBackground) - val clickableTextStyle = - SpanStyle(color = MaterialTheme.colors.primary) + val clickableTextStyle = + SpanStyle(color = MaterialTheme.colors.primary) - val annotatedTermsString = buildAnnotatedString { - withStyle(regularText) { - append(stringResource(R.string.i_accept_the)) - } - - withStyle(clickableTextStyle) { - pushStringAnnotation("openTerms", "") - append(stringResource(R.string.terms_of_use)) - } - } - - ClickableText( - text = annotatedTermsString - ) { spanOffset -> - annotatedTermsString.getStringAnnotations(spanOffset, spanOffset) - .firstOrNull() - ?.also { span -> - if (span.tag == "openTerms") { - runCatching { uri.openUri("https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md") } - } + val annotatedTermsString = buildAnnotatedString { + withStyle(regularText) { + append(stringResource(R.string.i_accept_the)) } - } - } - if (termsAcceptanceIsRequired.isNotBlank()) { - Text( - text = termsAcceptanceIsRequired, - color = MaterialTheme.colors.error, - style = MaterialTheme.typography.caption - ) + withStyle(clickableTextStyle) { + pushStringAnnotation("openTerms", "") + append(stringResource(R.string.terms_of_use)) + } + } + + ClickableText( + text = annotatedTermsString + ) { spanOffset -> + annotatedTermsString.getStringAnnotations(spanOffset, spanOffset) + .firstOrNull() + ?.also { span -> + if (span.tag == "openTerms") { + runCatching { uri.openUri("https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md") } + } + } + } + } + + if (termsAcceptanceIsRequired.isNotBlank()) { + Text( + text = termsAcceptanceIsRequired, + color = MaterialTheme.colors.error, + style = MaterialTheme.typography.caption + ) + } } Spacer(modifier = Modifier.height(20.dp)) From 992796a7bf05dd24a8761dfa4b094091d3341c16 Mon Sep 17 00:00:00 2001 From: maxmoney21m Date: Sun, 12 Mar 2023 12:00:01 +0800 Subject: [PATCH 06/13] Delete user-specific preference xml file on logout --- .../amethyst/LocalPreferences.kt | 42 +++++++++++++------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 78840dae1..c9698182d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst +import android.annotation.SuppressLint import android.content.Context import android.content.SharedPreferences import com.google.gson.GsonBuilder @@ -12,9 +13,12 @@ import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent import nostr.postr.Persona import nostr.postr.toHex +import java.io.File import java.util.Locale -// MUST BE SET TO FALSE FOR PRODUCTION!!!!! +// 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 const val DEBUG_PLAINTEXT_PREFERENCES = true data class AccountInfo(val npub: String, val current: Boolean, val displayName: String?, val profilePicture: String?) @@ -62,6 +66,9 @@ object LocalPreferences { }.apply() } + /** + * Removes the account from the app level shared preferences + */ private fun removeAccount(npub: String) { val accounts = savedAccounts.toMutableSet() accounts.remove(npub) @@ -71,8 +78,21 @@ object LocalPreferences { }.apply() } + /** + * Deletes the npub-specific shared preference file + */ + private fun deleteUserPreferenceFile(npub: String) { + val context = Amethyst.instance + val prefsDir = File("${context.filesDir.parent}/shared_prefs/") + prefsDir.list()?.forEach { + if (it.contains(npub)) { + File(prefsDir, it).delete() + } + } + } + private fun encryptedPreferences(npub: String? = null): SharedPreferences { - return if (DEBUG_PLAINTEXT_PREFERENCES) { + return if (BuildConfig.DEBUG && DEBUG_PLAINTEXT_PREFERENCES) { val preferenceFile = if (npub == null) "testing_only" else "testing_only_$npub" Amethyst.instance.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE) } else { @@ -80,10 +100,16 @@ object LocalPreferences { } } + /** + * 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 + */ + @SuppressLint("ApplySharedPref") fun clearEncryptedStorage(npub: String) { val userPrefs = encryptedPreferences(npub) - userPrefs.edit().clear().apply() + userPrefs.edit().clear().commit() removeAccount(npub) + deleteUserPreferenceFile(npub) if (savedAccounts.isEmpty()) { val appPrefs = encryptedPreferences() @@ -91,16 +117,6 @@ object LocalPreferences { } else if (currentAccount == npub) { currentAccount = savedAccounts.elementAt(0) } -// encPrefs.edit().apply { -// encPrefs.all.keys.forEach { -// remove(it) -// } -// encryptedPreferences.all.keys.filter { -// it.startsWith(npub) -// }.forEach { -// remove(it) -// } -// }.apply() } fun findAllLocalAccounts(): List { From 269197a59d744075ee2ab4baeee55a19550da95b Mon Sep 17 00:00:00 2001 From: maxmoney21m Date: Sun, 12 Mar 2023 13:24:51 +0800 Subject: [PATCH 07/13] Migration from old to new preferences --- .../amethyst/EncryptedStorage.kt | 6 +- .../amethyst/LocalPreferences.kt | 99 +++++++++++++++---- .../vitorpamplona/amethyst/ui/MainActivity.kt | 3 + .../ui/navigation/AccountSwitchBottomSheet.kt | 2 +- .../ui/screen/AccountStateViewModel.kt | 8 +- 5 files changed, 92 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt b/app/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt index 002c8a744..bfcea8192 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt @@ -6,13 +6,17 @@ import androidx.security.crypto.MasterKey object EncryptedStorage { private const val PREFERENCES_NAME = "secret_keeper" + fun prefsFileName(npub: String? = null): String { + return if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub" + } + fun preferences(npub: String? = null): EncryptedSharedPreferences { val context = Amethyst.instance val masterKey: MasterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build() - val preferencesName = if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub" + val preferencesName = prefsFileName(npub) return EncryptedSharedPreferences.create( context, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index c9698182d..89ffd99ac 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -11,17 +11,25 @@ import com.vitorpamplona.amethyst.model.toByteArray import com.vitorpamplona.amethyst.service.model.ContactListEvent import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent +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 -const val DEBUG_PLAINTEXT_PREFERENCES = true +private const val DEBUG_PLAINTEXT_PREFERENCES = false +private const val OLD_PREFS_FILENAME = "secret_keeper" -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? +) private object PrefKeys { const val CURRENT_ACCOUNT = "currently_logged_in_account" @@ -57,6 +65,9 @@ object LocalPreferences { private val savedAccounts: Set get() = encryptedPreferences().getStringSet(PrefKeys.SAVED_ACCOUNTS, null) ?: setOf() + private val prefsDirPath: String + get() = "${Amethyst.instance.filesDir.parent}/shared_prefs/" + private fun addAccount(npub: String) { val accounts = savedAccounts.toMutableSet() accounts.add(npub) @@ -66,6 +77,12 @@ object LocalPreferences { }.apply() } + private fun setCurrentAccount(account: Account) { + val npub = account.userProfile().pubkeyNpub() + currentAccount = npub + addAccount(npub) + } + /** * Removes the account from the app level shared preferences */ @@ -82,8 +99,7 @@ object LocalPreferences { * Deletes the npub-specific shared preference file */ private fun deleteUserPreferenceFile(npub: String) { - val context = Amethyst.instance - val prefsDir = File("${context.filesDir.parent}/shared_prefs/") + val prefsDir = File(prefsDirPath) prefsDir.list()?.forEach { if (it.contains(npub)) { File(prefsDir, it).delete() @@ -93,7 +109,7 @@ object LocalPreferences { private fun encryptedPreferences(npub: String? = null): SharedPreferences { return if (BuildConfig.DEBUG && DEBUG_PLAINTEXT_PREFERENCES) { - val preferenceFile = if (npub == null) "testing_only" else "testing_only_$npub" + val preferenceFile = if (npub == null) "debug_prefs" else "debug_prefs_$npub" Amethyst.instance.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE) } else { return EncryptedStorage.preferences(npub) @@ -103,9 +119,13 @@ object LocalPreferences { /** * 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 clearEncryptedStorage(npub: String) { + fun updatePrefsForLogout(npub: String) { val userPrefs = encryptedPreferences(npub) userPrefs.edit().clear().commit() removeAccount(npub) @@ -119,7 +139,12 @@ object LocalPreferences { } } - fun findAllLocalAccounts(): List { + fun updatePrefsForLogin(account: Account) { + setCurrentAccount(account) + saveToEncryptedStorage(account) + } + + fun allSavedAccounts(): List { return savedAccounts.map { npub -> val prefs = encryptedPreferences(npub) @@ -132,12 +157,6 @@ object LocalPreferences { } } - fun setCurrentAccount(account: Account) { - val npub = account.userProfile().pubkeyNpub() - currentAccount = npub - addAccount(npub) - } - fun saveToEncryptedStorage(account: Account) { val prefs = encryptedPreferences(account.userProfile().pubkeyNpub()) prefs.edit().apply { @@ -157,11 +176,6 @@ object LocalPreferences { }.apply() } - fun login(account: Account) { - setCurrentAccount(account) - saveToEncryptedStorage(account) - } - fun loadFromEncryptedStorage(): Account? { encryptedPreferences(currentAccount).apply { val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null @@ -183,7 +197,8 @@ object LocalPreferences { val latestContactList = try { 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) { e.printStackTrace() @@ -192,7 +207,10 @@ object LocalPreferences { val languagePreferences = try { getString(PrefKeys.LANGUAGE_PREFS, null)?.let { - gson.fromJson(it, object : TypeToken>() {}.type) as Map + gson.fromJson( + it, + object : TypeToken>() {}.type + ) as Map } ?: mapOf() } catch (e: Throwable) { e.printStackTrace() @@ -227,4 +245,45 @@ object LocalPreferences { 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_INFO, + appPrefs.getBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, false) + ) + }.apply() + } + + encryptedPreferences().edit().clear().apply() + addAccount(npub) + currentAccount = npub + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index a11394367..0cd9b18dd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -14,6 +14,7 @@ import coil.ImageLoader import coil.decode.GifDecoder import coil.decode.ImageDecoderDecoder import coil.decode.SvgDecoder +import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.ServiceManager import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.service.relays.Client @@ -48,6 +49,8 @@ class MainActivity : FragmentActivity() { .build() } + LocalPreferences.migrateSingleUserPrefs() + setContent { AmethystTheme { // A surface container using the 'background' color from the theme diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt index d090c46b5..fb751c5d6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt @@ -55,7 +55,7 @@ fun AccountSwitchBottomSheet( accountStateViewModel: AccountStateViewModel ) { val context = LocalContext.current - val accounts = LocalPreferences.findAllLocalAccounts() + val accounts = LocalPreferences.allSavedAccounts() val accountState by accountViewModel.accountLiveData.observeAsState() val account = accountState?.account ?: return diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index 707a877aa..eff4f583d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt @@ -51,18 +51,18 @@ class AccountStateViewModel() : ViewModel() { Account(Persona(Hex.decode(key))) } - LocalPreferences.login(account) + LocalPreferences.updatePrefsForLogin(account) login(account) } fun newKey() { val account = Account(Persona()) - LocalPreferences.login(account) + LocalPreferences.updatePrefsForLogin(account) login(account) } fun login(account: Account) { - LocalPreferences.login(account) + LocalPreferences.updatePrefsForLogin(account) if (account.loggedIn.privKey != null) { _accountContent.update { AccountState.LoggedIn(account) } @@ -105,7 +105,7 @@ class AccountStateViewModel() : ViewModel() { _accountContent.update { AccountState.LoggedOff } - LocalPreferences.clearEncryptedStorage(npub) + LocalPreferences.updatePrefsForLogout(npub) tryLoginExistingAccount() } } From f34d79106dfdc25c393a41c49ddecacd1467ed70 Mon Sep 17 00:00:00 2001 From: maxmoney21m Date: Sun, 12 Mar 2023 14:27:34 +0800 Subject: [PATCH 08/13] Fix account switching --- .../com/vitorpamplona/amethyst/LocalPreferences.kt | 4 ++++ .../ui/navigation/AccountSwitchBottomSheet.kt | 2 +- .../amethyst/ui/screen/AccountStateViewModel.kt | 11 ++++++++++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 89ffd99ac..5f5354b12 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -83,6 +83,10 @@ object LocalPreferences { addAccount(npub) } + fun switchToAccount(npub: String) { + currentAccount = npub + } + /** * Removes the account from the app level shared preferences */ diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt index fb751c5d6..0f414299e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt @@ -86,7 +86,7 @@ fun AccountSwitchBottomSheet( ) { Row( modifier = Modifier.clickable { - accountStateViewModel.login(acc.npub) + accountStateViewModel.switchUser(acc.npub) }, verticalAlignment = Alignment.CenterVertically ) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index eff4f583d..10cc60808 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt @@ -55,6 +55,12 @@ class AccountStateViewModel() : ViewModel() { login(account) } + fun switchUser(npub: String) { + prepareLogoutOrSwitch() + LocalPreferences.switchToAccount(npub) + tryLoginExistingAccount() + } + fun newKey() { val account = Account(Persona()) LocalPreferences.updatePrefsForLogin(account) @@ -86,7 +92,7 @@ class AccountStateViewModel() : ViewModel() { } } - fun logOff(npub: String) { + private fun prepareLogoutOrSwitch() { val state = accountContent.value when (state) { @@ -104,7 +110,10 @@ class AccountStateViewModel() : ViewModel() { } _accountContent.update { AccountState.LoggedOff } + } + fun logOff(npub: String) { + prepareLogoutOrSwitch() LocalPreferences.updatePrefsForLogout(npub) tryLoginExistingAccount() } From b1cb7d599fd5b92adc0bd81f74f67fefb13dd029 Mon Sep 17 00:00:00 2001 From: maxmoney21m Date: Sun, 12 Mar 2023 20:34:50 +0800 Subject: [PATCH 09/13] Make account switcher scrollable --- .idea/deploymentTargetDropDown.xml | 17 ----------------- .../ui/navigation/AccountSwitchBottomSheet.kt | 5 ++++- 2 files changed, 4 insertions(+), 18 deletions(-) delete mode 100644 .idea/deploymentTargetDropDown.xml diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml deleted file mode 100644 index 0c0237e9c..000000000 --- a/.idea/deploymentTargetDropDown.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt index 0f414299e..0541d2351 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt @@ -11,7 +11,9 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme @@ -64,8 +66,9 @@ fun AccountSwitchBottomSheet( val accountUser = accountUserState?.user ?: return var popupExpanded by remember { mutableStateOf(false) } + val scrollState = rememberScrollState() - Column { + Column(modifier = Modifier.verticalScroll(scrollState)) { Row( modifier = Modifier .fillMaxWidth() From 32b50418de02b27c436f1468be50b5a16c45f5f5 Mon Sep 17 00:00:00 2001 From: maxmoney21m Date: Sun, 12 Mar 2023 21:20:00 +0800 Subject: [PATCH 10/13] Indicate whether account has pubkey or privkey --- .../amethyst/LocalPreferences.kt | 3 + .../ui/navigation/AccountSwitchBottomSheet.kt | 80 +++++++++++++------ 2 files changed, 60 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 5f5354b12..4b41607b4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -26,6 +26,7 @@ private const val OLD_PREFS_FILENAME = "secret_keeper" data class AccountInfo( val npub: String, + val hasPrivKey: Boolean, val current: Boolean, val displayName: String?, val profilePicture: String? @@ -151,9 +152,11 @@ object LocalPreferences { fun allSavedAccounts(): List { return savedAccounts.map { npub -> val prefs = encryptedPreferences(npub) + val hasPrivKey = prefs.getString(PrefKeys.NOSTR_PRIVKEY, null) != null AccountInfo( npub = npub, + hasPrivKey = hasPrivKey, current = npub == currentAccount, displayName = prefs.getString(PrefKeys.DISPLAY_NAME, null), profilePicture = prefs.getString(PrefKeys.PROFILE_PICTURE_URL, null) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt index 0541d2351..d0d8a0010 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape @@ -23,7 +24,10 @@ import androidx.compose.material.TextButton import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.Logout +import androidx.compose.material.icons.filled.RadioButtonChecked +import androidx.compose.material.icons.filled.Visibility import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -84,41 +88,71 @@ fun AccountSwitchBottomSheet( Row( modifier = Modifier .fillMaxWidth() - .padding(32.dp, 16.dp), + .padding(16.dp, 16.dp), verticalAlignment = Alignment.CenterVertically ) { Row( - modifier = Modifier.clickable { + modifier = Modifier.weight(1f).clickable { accountStateViewModel.switchUser(acc.npub) }, verticalAlignment = Alignment.CenterVertically ) { - AsyncImageProxy( - model = ResizeImage(acc.profilePicture, 64.dp), - placeholder = BitmapPainter(RoboHashCache.get(context, acc.npub)), - fallback = BitmapPainter(RoboHashCache.get(context, acc.npub)), - error = BitmapPainter(RoboHashCache.get(context, acc.npub)), - contentDescription = stringResource(id = R.string.profile_image), - modifier = Modifier - .width(64.dp) - .height(64.dp) - .clip(shape = CircleShape) - ) - Spacer(modifier = Modifier.width(16.dp)) - Column { - acc.displayName?.let { - Text(it) + Box( + modifier = Modifier.width(55.dp).padding(0.dp) + ) { + AsyncImageProxy( + model = ResizeImage(acc.profilePicture, 55.dp), + placeholder = BitmapPainter(RoboHashCache.get(context, acc.npub)), + fallback = BitmapPainter(RoboHashCache.get(context, acc.npub)), + error = BitmapPainter(RoboHashCache.get(context, acc.npub)), + contentDescription = stringResource(id = R.string.profile_image), + modifier = Modifier + .width(55.dp) + .height(55.dp) + .clip(shape = CircleShape) + ) + + Box( + modifier = Modifier.size(20.dp).align(Alignment.TopEnd) + ) { + if (acc.hasPrivKey) { + Icon( + imageVector = Icons.Default.Key, + contentDescription = "Has private key", + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colors.primary + ) + } else { + Icon( + imageVector = Icons.Default.Visibility, + contentDescription = "Read only, no private key", + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colors.primary + ) + } } - Text(acc.npub.toShortenHex()) } - Spacer(modifier = Modifier.width(8.dp)) - if (current) { - Text("✓") + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + val npubShortHex = acc.npub.toShortenHex() + + if (acc.displayName != null && acc.displayName != npubShortHex) { + Text(acc.displayName) + } + + Text(npubShortHex) + } + Column(modifier = Modifier.width(32.dp)) { + if (current) { + Icon( + imageVector = Icons.Default.RadioButtonChecked, + contentDescription = "Active account", + tint = MaterialTheme.colors.secondary + ) + } } } - Spacer(modifier = Modifier.weight(1f)) - IconButton( onClick = { accountStateViewModel.logOff(acc.npub) } ) { From cc36dcffe0e503a898a4a258971336052c4a3f3a Mon Sep 17 00:00:00 2001 From: maxmoney21m Date: Sun, 12 Mar 2023 21:57:32 +0800 Subject: [PATCH 11/13] Extract strings --- .../amethyst/LocalPreferences.kt | 4 +-- .../ui/navigation/AccountSwitchBottomSheet.kt | 34 +++++++++++-------- .../amethyst/ui/navigation/DrawerContent.kt | 17 ++-------- .../amethyst/ui/screen/loggedIn/MainScreen.kt | 2 +- app/src/main/res/values/strings.xml | 8 +++++ 5 files changed, 34 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 4b41607b4..31548102e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -22,7 +22,7 @@ import java.util.Locale // 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 OLD_PREFS_FILENAME = "secret_keeper" +private const val DEBUG_PREFERENCES_NAME = "debug_prefs" data class AccountInfo( val npub: String, @@ -114,7 +114,7 @@ object LocalPreferences { private fun encryptedPreferences(npub: String? = null): SharedPreferences { return if (BuildConfig.DEBUG && DEBUG_PLAINTEXT_PREFERENCES) { - val preferenceFile = if (npub == null) "debug_prefs" else "debug_prefs_$npub" + 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) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt index d0d8a0010..6b793447d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt @@ -80,7 +80,7 @@ fun AccountSwitchBottomSheet( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { - Text("Select Account", fontWeight = FontWeight.Bold) + Text(stringResource(R.string.account_switch_select_account), fontWeight = FontWeight.Bold) } accounts.forEach { acc -> val current = accountUser.pubkeyNpub() == acc.npub @@ -92,20 +92,24 @@ fun AccountSwitchBottomSheet( verticalAlignment = Alignment.CenterVertically ) { Row( - modifier = Modifier.weight(1f).clickable { - accountStateViewModel.switchUser(acc.npub) - }, + modifier = Modifier + .weight(1f) + .clickable { + accountStateViewModel.switchUser(acc.npub) + }, verticalAlignment = Alignment.CenterVertically ) { Box( - modifier = Modifier.width(55.dp).padding(0.dp) + modifier = Modifier + .width(55.dp) + .padding(0.dp) ) { AsyncImageProxy( model = ResizeImage(acc.profilePicture, 55.dp), placeholder = BitmapPainter(RoboHashCache.get(context, acc.npub)), fallback = BitmapPainter(RoboHashCache.get(context, acc.npub)), error = BitmapPainter(RoboHashCache.get(context, acc.npub)), - contentDescription = stringResource(id = R.string.profile_image), + contentDescription = stringResource(R.string.profile_image), modifier = Modifier .width(55.dp) .height(55.dp) @@ -113,19 +117,21 @@ fun AccountSwitchBottomSheet( ) Box( - modifier = Modifier.size(20.dp).align(Alignment.TopEnd) + modifier = Modifier + .size(20.dp) + .align(Alignment.TopEnd) ) { if (acc.hasPrivKey) { Icon( imageVector = Icons.Default.Key, - contentDescription = "Has private key", + contentDescription = stringResource(R.string.account_switch_has_private_key), modifier = Modifier.size(20.dp), tint = MaterialTheme.colors.primary ) } else { Icon( imageVector = Icons.Default.Visibility, - contentDescription = "Read only, no private key", + contentDescription = stringResource(R.string.account_switch_pubkey_only), modifier = Modifier.size(20.dp), tint = MaterialTheme.colors.primary ) @@ -146,7 +152,7 @@ fun AccountSwitchBottomSheet( if (current) { Icon( imageVector = Icons.Default.RadioButtonChecked, - contentDescription = "Active account", + contentDescription = stringResource(R.string.account_switch_active_account), tint = MaterialTheme.colors.secondary ) } @@ -158,7 +164,7 @@ fun AccountSwitchBottomSheet( ) { Icon( imageVector = Icons.Default.Logout, - contentDescription = "Logout", + contentDescription = stringResource(R.string.log_out), tint = MaterialTheme.colors.onSurface ) } @@ -172,7 +178,7 @@ fun AccountSwitchBottomSheet( verticalAlignment = Alignment.CenterVertically ) { TextButton(onClick = { popupExpanded = true }) { - Text("Add New Account") + Text(stringResource(R.string.account_switch_add_account_btn)) } } } @@ -186,12 +192,12 @@ fun AccountSwitchBottomSheet( Box { LoginPage(accountStateViewModel, isFirstLogin = false) TopAppBar( - title = { Text(text = "Add New Account") }, + title = { Text(text = stringResource(R.string.account_switch_add_account_dialog_title)) }, navigationIcon = { IconButton(onClick = { popupExpanded = false }) { Icon( imageVector = Icons.Default.ArrowBack, - contentDescription = "Back", + contentDescription = stringResource(R.string.back), tint = MaterialTheme.colors.onSurface ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index 8198b7058..09c1aac42 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -53,7 +53,6 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy import com.vitorpamplona.amethyst.ui.components.ResizeImage -import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.coroutines.launch @@ -64,8 +63,7 @@ fun DrawerContent( navController: NavHostController, scaffoldState: ScaffoldState, sheetState: ModalBottomSheetState, - accountViewModel: AccountViewModel, - accountStateViewModel: AccountStateViewModel + accountViewModel: AccountViewModel ) { val accountState by accountViewModel.accountLiveData.observeAsState() val account = accountState?.account ?: return @@ -95,8 +93,7 @@ fun DrawerContent( sheetState, modifier = Modifier .fillMaxWidth() - .weight(1F), - accountStateViewModel, + .weight(1f), account ) @@ -227,7 +224,6 @@ fun ListContent( scaffoldState: ScaffoldState, sheetState: ModalBottomSheetState, modifier: Modifier, - accountViewModel: AccountStateViewModel, account: Account ) { val coroutineScope = rememberCoroutineScope() @@ -268,18 +264,11 @@ fun ListContent( Spacer(modifier = Modifier.weight(1f)) IconRow( - title = "Accounts", + title = stringResource(R.string.drawer_accounts), icon = R.drawable.manage_accounts, tint = MaterialTheme.colors.onBackground, onClick = { coroutineScope.launch { sheetState.show() } } ) - -// IconRow( -// title = stringResource(R.string.log_out), -// icon = R.drawable.ic_logout, -// tint = MaterialTheme.colors.onBackground, -// onClick = { accountViewModel.logOff() } -// ) } if (backupDialogOpen) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt index 90fac6a13..ff5f893e7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt @@ -61,7 +61,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun AppTopBar(navController, scaffoldState, accountViewModel) }, drawerContent = { - DrawerContent(navController, scaffoldState, sheetState, accountViewModel, accountStateViewModel) + DrawerContent(navController, scaffoldState, sheetState, accountViewModel) }, floatingActionButton = { FloatingButton(navController, accountStateViewModel) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a10ecee71..9874b9513 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -220,5 +220,13 @@ "<Unable to decrypt private message>\n\nYou were cited in a private/encrypted conversation between %1$s and %2$s." Delete Don\'t show again + Add New Account + Accounts + Select Account + Add New Account + Active account + Has private key + Read only, no private key + Back From 20431a87eaa4b73bee35afee6f1cf6bea634d77b Mon Sep 17 00:00:00 2001 From: maxmoney21m Date: Sun, 12 Mar 2023 22:26:38 +0800 Subject: [PATCH 12/13] Use List instead of Set to keep account order --- .../amethyst/LocalPreferences.kt | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 31548102e..0d60e8885 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -54,6 +54,8 @@ private object PrefKeys { private val gson = GsonBuilder().create() object LocalPreferences { + private const val comma = "," + private var currentAccount: String? get() = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null) set(npub) { @@ -63,18 +65,21 @@ object LocalPreferences { }.apply() } - private val savedAccounts: Set - get() = encryptedPreferences().getStringSet(PrefKeys.SAVED_ACCOUNTS, null) ?: setOf() + private val savedAccounts: List + get() = encryptedPreferences() + .getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(comma) ?: listOf() private val prefsDirPath: String get() = "${Amethyst.instance.filesDir.parent}/shared_prefs/" private fun addAccount(npub: String) { - val accounts = savedAccounts.toMutableSet() - accounts.add(npub) + val accounts = savedAccounts.toMutableList() + if (npub !in accounts) { + accounts.add(npub) + } val prefs = encryptedPreferences() prefs.edit().apply { - putStringSet(PrefKeys.SAVED_ACCOUNTS, accounts) + putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma)) }.apply() } @@ -92,12 +97,13 @@ object LocalPreferences { * Removes the account from the app level shared preferences */ private fun removeAccount(npub: String) { - val accounts = savedAccounts.toMutableSet() - accounts.remove(npub) - val prefs = encryptedPreferences() - prefs.edit().apply { - putStringSet(PrefKeys.SAVED_ACCOUNTS, accounts) - }.apply() + val accounts = savedAccounts.toMutableList() + if (accounts.remove(npub)) { + val prefs = encryptedPreferences() + prefs.edit().apply { + putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma)) + }.apply() + } } /** From 2fea7dac6272b6963867d6ec0f105d2e6832d380 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sun, 12 Mar 2023 18:42:17 +0100 Subject: [PATCH 13/13] Refactor LocalCache.isValidHexNpub() --- .../amethyst/model/LocalCache.kt | 68 ++++++------------- 1 file changed, 19 insertions(+), 49 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index d6fdfcdb9..7d1375df9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -5,37 +5,10 @@ import androidx.lifecycle.LiveData import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.google.gson.reflect.TypeToken -import com.vitorpamplona.amethyst.service.model.ATag -import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent -import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent -import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent -import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent -import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent -import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent -import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent -import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent -import com.vitorpamplona.amethyst.service.model.ContactListEvent -import com.vitorpamplona.amethyst.service.model.DeletionEvent -import com.vitorpamplona.amethyst.service.model.Event -import com.vitorpamplona.amethyst.service.model.LnZapEvent -import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent -import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent -import com.vitorpamplona.amethyst.service.model.MetadataEvent -import com.vitorpamplona.amethyst.service.model.PrivateDmEvent -import com.vitorpamplona.amethyst.service.model.ReactionEvent -import com.vitorpamplona.amethyst.service.model.RecommendRelayEvent -import com.vitorpamplona.amethyst.service.model.ReportEvent -import com.vitorpamplona.amethyst.service.model.RepostEvent -import com.vitorpamplona.amethyst.service.model.TextNoteEvent +import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.relays.Relay import fr.acinq.secp256k1.Hex -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import kotlinx.coroutines.* import nostr.postr.toNpub import java.io.ByteArrayInputStream import java.time.Instant @@ -57,13 +30,10 @@ object LocalCache { val addressables = ConcurrentHashMap() fun checkGetOrCreateUser(key: String): User? { - return try { - checkIfValidHex(key) - getOrCreateUser(key) - } catch (e: IllegalArgumentException) { - Log.e("LocalCache", "Invalid Key to create user: $key", e) - null + if (isValidHexNpub(key)) { + return getOrCreateUser(key) } + return null } @Synchronized @@ -79,13 +49,10 @@ object LocalCache { if (ATag.isATag(key)) { return checkGetOrCreateAddressableNote(key) } - return try { - checkIfValidHex(key) - getOrCreateNote(key) - } catch (e: IllegalArgumentException) { - Log.e("LocalCache", "Invalid Key to create note: $key", e) - null + if (isValidHexNpub(key)) { + return getOrCreateNote(key) } + return null } @Synchronized @@ -98,17 +65,20 @@ object LocalCache { } fun checkGetOrCreateChannel(key: String): Channel? { - return try { - checkIfValidHex(key) - getOrCreateChannel(key) - } catch (e: IllegalArgumentException) { - Log.e("LocalCache", "Invalid Key to create channel: $key", e) - null + if (isValidHexNpub(key)) { + return getOrCreateChannel(key) } + return null } - private fun checkIfValidHex(key: String) { - Hex.decode(key).toNpub() + private fun isValidHexNpub(key: String): Boolean { + return try { + Hex.decode(key).toNpub() + true + } catch (e: IllegalArgumentException) { + Log.e("LocalCache", "Invalid Key to create user: $key", e) + false + } } @Synchronized