Merge pull request #263 from maxmoney21m/feature/multiple-accounts

Multiple account management
pull/267/head
Vitor Pamplona 2023-03-12 18:09:50 -04:00 zatwierdzone przez GitHub
commit 9fc1fa9f6c
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
16 zmienionych plików z 696 dodań i 173 usunięć

Wyświetl plik

@ -13,6 +13,7 @@
<application
android:allowBackup="false"
android:name=".Amethyst"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@drawable/amethyst"

Wyświetl plik

@ -0,0 +1,15 @@
package com.vitorpamplona.amethyst
import android.app.Application
class Amethyst : Application() {
override fun onCreate() {
super.onCreate()
instance = this
}
companion object {
lateinit var instance: Amethyst
private set
}
}

Wyświetl plik

@ -1,20 +1,26 @@
package com.vitorpamplona.amethyst
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
object EncryptedStorage {
private const val PREFERENCES_NAME = "secret_keeper"
fun preferences(context: Context): EncryptedSharedPreferences {
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 = prefsFileName(npub)
return EncryptedSharedPreferences.create(
context,
PREFERENCES_NAME,
preferencesName,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM

Wyświetl plik

@ -1,6 +1,8 @@
package com.vitorpamplona.amethyst
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.Account
@ -9,55 +11,188 @@ 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
class LocalPreferences(context: Context) {
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"
val LAST_READ: (String) -> 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<String>
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<AccountInfo> {
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<Map<String, String>>() {}.type) as Map<String, String>
} ?: mapOf<String, String>()
gson.fromJson(
it,
object : TypeToken<Map<String, String>>() {}.type
) as Map<String, String>
} ?: mapOf()
} catch (e: Throwable) {
e.printStackTrace()
mapOf<String, String>()
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
}
}

Wyświetl plik

@ -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

Wyświetl plik

@ -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)

Wyświetl plik

@ -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
)
}
}
}
}
}

Wyświetl plik

@ -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() } }
)
}

Wyświetl plik

@ -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 {

Wyświetl plik

@ -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)

Wyświetl plik

@ -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>(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()
}
}

Wyświetl plik

@ -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

Wyświetl plik

@ -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)
}
}
}
}

Wyświetl plik

@ -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))

Wyświetl plik

@ -0,0 +1,7 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M10,8m-4,0a4,4 0,1 1,8 0a4,4 0,1 1,-8 0"/>
<path android:fillColor="@android:color/white" android:pathData="M10.67,13.02C10.45,13.01 10.23,13 10,13c-2.42,0 -4.68,0.67 -6.61,1.82C2.51,15.34 2,16.32 2,17.35V20h9.26C10.47,18.87 10,17.49 10,16C10,14.93 10.25,13.93 10.67,13.02z"/>
<path android:fillColor="@android:color/white" android:pathData="M20.75,16c0,-0.22 -0.03,-0.42 -0.06,-0.63l1.14,-1.01l-1,-1.73l-1.45,0.49c-0.32,-0.27 -0.68,-0.48 -1.08,-0.63L18,11h-2l-0.3,1.49c-0.4,0.15 -0.76,0.36 -1.08,0.63l-1.45,-0.49l-1,1.73l1.14,1.01c-0.03,0.21 -0.06,0.41 -0.06,0.63s0.03,0.42 0.06,0.63l-1.14,1.01l1,1.73l1.45,-0.49c0.32,0.27 0.68,0.48 1.08,0.63L16,21h2l0.3,-1.49c0.4,-0.15 0.76,-0.36 1.08,-0.63l1.45,0.49l1,-1.73l-1.14,-1.01C20.72,16.42 20.75,16.22 20.75,16zM17,18c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2s2,0.9 2,2S18.1,18 17,18z"/>
</vector>

Wyświetl plik

@ -220,5 +220,13 @@
<string name="private_conversation_notification">"&lt;Unable to decrypt private message&gt;\n\nYou were cited in a private/encrypted conversation between %1$s and %2$s."</string>
<string name="quick_action_delete_button">Delete</string>
<string name="quick_action_dont_show_again_button">Don\'t show again</string>
<string name="account_switch_add_account_dialog_title">Add New Account</string>
<string name="drawer_accounts">Accounts</string>
<string name="account_switch_select_account">Select Account</string>
<string name="account_switch_add_account_btn">Add New Account</string>
<string name="account_switch_active_account">Active account</string>
<string name="account_switch_has_private_key">Has private key</string>
<string name="account_switch_pubkey_only">Read only, no private key</string>
<string name="back">Back</string>
</resources>