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" } - } +// Release mode (!BuildConfig.DEBUG) always uses encrypted preferences +// To use plaintext SharedPreferences for debugging, set this to true +// It will only apply in Debug builds +private const val DEBUG_PLAINTEXT_PREFERENCES = false +private const val DEBUG_PREFERENCES_NAME = "debug_prefs" - private val encryptedPreferences = EncryptedStorage.preferences(context) - private val gson = GsonBuilder().create() +data class AccountInfo( + val npub: String, + val hasPrivKey: Boolean, + val current: Boolean, + val displayName: String?, + val profilePicture: String? +) - fun clearEncryptedStorage() { - encryptedPreferences.edit().apply { - encryptedPreferences.all.keys.forEach { remove(it) } +private object PrefKeys { + const val CURRENT_ACCOUNT = "currently_logged_in_account" + const val SAVED_ACCOUNTS = "all_saved_accounts" + const val NOSTR_PRIVKEY = "nostr_privkey" + const val NOSTR_PUBKEY = "nostr_pubkey" + const val DISPLAY_NAME = "display_name" + const val PROFILE_PICTURE_URL = "profile_picture" + const val FOLLOWING_CHANNELS = "following_channels" + const val HIDDEN_USERS = "hidden_users" + const val RELAYS = "relays" + const val DONT_TRANSLATE_FROM = "dontTranslateFrom" + const val LANGUAGE_PREFS = "languagePreferences" + const val TRANSLATE_TO = "translateTo" + const val ZAP_AMOUNTS = "zapAmounts" + const val LATEST_CONTACT_LIST = "latestContactList" + const val HIDE_DELETE_REQUEST_INFO = "hideDeleteRequestInfo" + val LAST_READ: (String) -> String = { route -> "last_read_route_$route" } +} + +private val gson = GsonBuilder().create() + +object LocalPreferences { + private const val comma = "," + + 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: 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.toMutableList() + if (npub !in accounts) { + accounts.add(npub) + } + val prefs = encryptedPreferences() + prefs.edit().apply { + putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma)) }.apply() } + private fun setCurrentAccount(account: Account) { + val npub = account.userProfile().pubkeyNpub() + currentAccount = npub + addAccount(npub) + } + + fun switchToAccount(npub: String) { + currentAccount = npub + } + + /** + * Removes the account from the app level shared preferences + */ + private fun removeAccount(npub: String) { + val accounts = savedAccounts.toMutableList() + if (accounts.remove(npub)) { + val prefs = encryptedPreferences() + prefs.edit().apply { + putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma)) + }.apply() + } + } + + /** + * Deletes the npub-specific shared preference file + */ + private fun deleteUserPreferenceFile(npub: String) { + val prefsDir = File(prefsDirPath) + prefsDir.list()?.forEach { + if (it.contains(npub)) { + File(prefsDir, it).delete() + } + } + } + + private fun encryptedPreferences(npub: String? = null): SharedPreferences { + return if (BuildConfig.DEBUG && DEBUG_PLAINTEXT_PREFERENCES) { + val preferenceFile = if (npub == null) DEBUG_PREFERENCES_NAME else "${DEBUG_PREFERENCES_NAME}_$npub" + Amethyst.instance.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE) + } else { + return EncryptedStorage.preferences(npub) + } + } + + /** + * Clears the preferences for a given npub, deletes the preferences xml file, + * and switches the user to the first account in the list if it exists + * + * We need to use `commit()` to write changes to disk and release the file + * lock so that it can be deleted. If we use `apply()` there is a race + * condition and the file will probably not be deleted + */ + @SuppressLint("ApplySharedPref") + fun updatePrefsForLogout(npub: String) { + val userPrefs = encryptedPreferences(npub) + userPrefs.edit().clear().commit() + removeAccount(npub) + deleteUserPreferenceFile(npub) + + if (savedAccounts.isEmpty()) { + val appPrefs = encryptedPreferences() + appPrefs.edit().clear().apply() + } else if (currentAccount == npub) { + currentAccount = savedAccounts.elementAt(0) + } + } + + fun updatePrefsForLogin(account: Account) { + setCurrentAccount(account) + saveToEncryptedStorage(account) + } + + 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) + ) + } + } + fun saveToEncryptedStorage(account: Account) { - encryptedPreferences.edit().apply { + 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()) } - 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)) } + 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 { + encryptedPreferences(currentAccount).apply { + val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null 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 localRelays = gson.fromJson( @@ -75,7 +210,8 @@ class LocalPreferences(context: Context) { 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() @@ -84,43 +220,83 @@ class LocalPreferences(context: Context) { val languagePreferences = try { getString(PrefKeys.LANGUAGE_PREFS, null)?.let { - gson.fromJson(it, object : TypeToken>() {}.type) as Map - } ?: mapOf() + gson.fromJson( + it, + object : TypeToken>() {}.type + ) as Map + } ?: mapOf() } catch (e: Throwable) { e.printStackTrace() - mapOf() + mapOf() } val hideDeleteRequestInfo = getBoolean(PrefKeys.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 + ) } } 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) } } + + 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/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..0cd9b18dd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -49,12 +49,14 @@ class MainActivity : FragmentActivity() { .build() } + LocalPreferences.migrateSingleUserPrefs() + setContent { AmethystTheme { // 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 new file mode 100644 index 000000000..6b793447d --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt @@ -0,0 +1,212 @@ +package com.vitorpamplona.amethyst.ui.navigation + +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.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 +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +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.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 +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +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 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 com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage + +@Composable +fun AccountSwitchBottomSheet( + accountViewModel: AccountViewModel, + accountStateViewModel: AccountStateViewModel +) { + val context = LocalContext.current + val accounts = LocalPreferences.allSavedAccounts() + + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account ?: return + + val accountUserState by account.userProfile().live().metadata.observeAsState() + val accountUser = accountUserState?.user ?: return + + var popupExpanded by remember { mutableStateOf(false) } + val scrollState = rememberScrollState() + + Column(modifier = Modifier.verticalScroll(scrollState)) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text(stringResource(R.string.account_switch_select_account), fontWeight = FontWeight.Bold) + } + accounts.forEach { acc -> + val current = accountUser.pubkeyNpub() == acc.npub + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp, 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier + .weight(1f) + .clickable { + accountStateViewModel.switchUser(acc.npub) + }, + verticalAlignment = Alignment.CenterVertically + ) { + 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(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 = stringResource(R.string.account_switch_has_private_key), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colors.primary + ) + } else { + Icon( + imageVector = Icons.Default.Visibility, + contentDescription = stringResource(R.string.account_switch_pubkey_only), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colors.primary + ) + } + } + } + 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 = stringResource(R.string.account_switch_active_account), + tint = MaterialTheme.colors.secondary + ) + } + } + } + + IconButton( + onClick = { accountStateViewModel.logOff(acc.npub) } + ) { + Icon( + imageVector = Icons.Default.Logout, + contentDescription = stringResource(R.string.log_out), + tint = MaterialTheme.colors.onSurface + ) + } + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = { popupExpanded = true }) { + Text(stringResource(R.string.account_switch_add_account_btn)) + } + } + } + + if (popupExpanded) { + Dialog( + onDismissRequest = { popupExpanded = false }, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface(modifier = Modifier.fillMaxSize()) { + Box { + LoginPage(accountStateViewModel, isFirstLogin = false) + TopAppBar( + title = { Text(text = stringResource(R.string.account_switch_add_account_dialog_title)) }, + navigationIcon = { + IconButton(onClick = { popupExpanded = false }) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = stringResource(R.string.back), + tint = MaterialTheme.colors.onSurface + ) + } + }, + backgroundColor = Color.Transparent, + elevation = 0.dp + ) + } + } + } + } +} 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..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 @@ -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 @@ -51,17 +53,17 @@ 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 +@OptIn(ExperimentalMaterialApi::class) @Composable fun DrawerContent( navController: NavHostController, scaffoldState: ScaffoldState, - accountViewModel: AccountViewModel, - accountStateViewModel: AccountStateViewModel + sheetState: ModalBottomSheetState, + accountViewModel: AccountViewModel ) { val accountState by accountViewModel.accountLiveData.observeAsState() val account = accountState?.account ?: return @@ -88,10 +90,10 @@ fun DrawerContent( account.userProfile(), navController, scaffoldState, + sheetState, modifier = Modifier .fillMaxWidth() - .weight(1F), - accountStateViewModel, + .weight(1f), account ) @@ -214,15 +216,17 @@ 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,10 +264,10 @@ fun ListContent( Spacer(modifier = Modifier.weight(1f)) IconRow( - stringResource(R.string.log_out), - R.drawable.ic_logout, - MaterialTheme.colors.onBackground, - onClick = { accountViewModel.logOff() } + title = stringResource(R.string.drawer_accounts), + icon = R.drawable.manage_accounts, + tint = MaterialTheme.colors.onBackground, + onClick = { coroutineScope.launch { sheetState.show() } } ) } 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/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/AccountStateViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index e57a4ae1b..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 @@ -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,10 +26,14 @@ 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 { + tryLoginExistingAccount() + // } + } + + private fun tryLoginExistingAccount() { + LocalPreferences.loadFromEncryptedStorage()?.let { login(it) } - // } } fun login(key: String) { @@ -47,18 +51,25 @@ class AccountStateViewModel(private val localPreferences: LocalPreferences) : Vi Account(Persona(Hex.decode(key))) } - localPreferences.saveToEncryptedStorage(account) - + LocalPreferences.updatePrefsForLogin(account) login(account) } + fun switchUser(npub: String) { + prepareLogoutOrSwitch() + LocalPreferences.switchToAccount(npub) + tryLoginExistingAccount() + } + fun newKey() { val account = Account(Persona()) - localPreferences.saveToEncryptedStorage(account) + LocalPreferences.updatePrefsForLogin(account) login(account) } fun login(account: Account) { + LocalPreferences.updatePrefsForLogin(account) + if (account.loggedIn.privKey != null) { _accountContent.update { AccountState.LoggedIn(account) } } else { @@ -77,11 +88,11 @@ 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) } } - fun logOff() { + private fun prepareLogoutOrSwitch() { val state = accountContent.value when (state) { @@ -99,7 +110,11 @@ class AccountStateViewModel(private val localPreferences: LocalPreferences) : Vi } _accountContent.update { AccountState.LoggedOff } + } - localPreferences.clearEncryptedStorage() + fun logOff(npub: String) { + prepareLogoutOrSwitch() + LocalPreferences.updatePrefsForLogout(npub) + tryLoginExistingAccount() } } 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..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 @@ -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, accountStateViewModel = accountStateViewModel) + } ) { - 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) + }, + floatingActionButton = { + FloatingButton(navController, accountStateViewModel) + }, + scaffoldState = scaffoldState + ) { + Column(modifier = Modifier.padding(bottom = it.calculateBottomPadding())) { + AppNavigation(navController, accountViewModel, accountStateViewModel, startingPage) + } } } } 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)) 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 @@ + + + + + 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