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

Multiple account management
This commit is contained in:
Vitor Pamplona
2023-03-12 18:09:50 -04:00
committed by GitHub
16 changed files with 696 additions and 173 deletions

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
package com.vitorpamplona.amethyst package com.vitorpamplona.amethyst
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
@@ -9,55 +11,188 @@ import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.service.model.ContactListEvent import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent
import fr.acinq.secp256k1.Hex
import nostr.postr.Persona import nostr.postr.Persona
import nostr.postr.toHex import nostr.postr.toHex
import nostr.postr.toNpub
import java.io.File
import java.util.Locale import java.util.Locale
class LocalPreferences(context: Context) { // Release mode (!BuildConfig.DEBUG) always uses encrypted preferences
private object PrefKeys { // To use plaintext SharedPreferences for debugging, set this to true
const val NOSTR_PRIVKEY = "nostr_privkey" // It will only apply in Debug builds
const val NOSTR_PUBKEY = "nostr_pubkey" private const val DEBUG_PLAINTEXT_PREFERENCES = false
const val FOLLOWING_CHANNELS = "following_channels" private const val DEBUG_PREFERENCES_NAME = "debug_prefs"
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 encryptedPreferences = EncryptedStorage.preferences(context) data class AccountInfo(
private val gson = GsonBuilder().create() val npub: String,
val hasPrivKey: Boolean,
val current: Boolean,
val displayName: String?,
val profilePicture: String?
)
fun clearEncryptedStorage() { private object PrefKeys {
encryptedPreferences.edit().apply { const val CURRENT_ACCOUNT = "currently_logged_in_account"
encryptedPreferences.all.keys.forEach { remove(it) } 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() }.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) { 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.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHex()) }
account.loggedIn.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHex()) } account.loggedIn.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHex()) }
account.followingChannels.let { putStringSet(PrefKeys.FOLLOWING_CHANNELS, it) } putStringSet(PrefKeys.FOLLOWING_CHANNELS, account.followingChannels)
account.hiddenUsers.let { putStringSet(PrefKeys.HIDDEN_USERS, it) } putStringSet(PrefKeys.HIDDEN_USERS, account.hiddenUsers)
account.localRelays.let { putString(PrefKeys.RELAYS, gson.toJson(it)) } putString(PrefKeys.RELAYS, gson.toJson(account.localRelays))
account.dontTranslateFrom.let { putStringSet(PrefKeys.DONT_TRANSLATE_FROM, it) } putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom)
account.languagePreferences.let { putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(it)) } putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(account.languagePreferences))
account.translateTo.let { putString(PrefKeys.TRANSLATE_TO, it) } putString(PrefKeys.TRANSLATE_TO, account.translateTo)
account.zapAmountChoices.let { putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(it)) } putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices))
account.backupContactList.let { putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(it)) } putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, account.hideDeleteRequestInfo) putBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, account.hideDeleteRequestInfo)
putString(PrefKeys.DISPLAY_NAME, account.userProfile().toBestDisplayName())
putString(PrefKeys.PROFILE_PICTURE_URL, account.userProfile().profilePicture())
}.apply() }.apply()
} }
fun loadFromEncryptedStorage(): Account? { fun loadFromEncryptedStorage(): Account? {
encryptedPreferences.apply { encryptedPreferences(currentAccount).apply {
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null) val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null)
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf() val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
val hiddenUsers = getStringSet(PrefKeys.HIDDEN_USERS, emptySet()) ?: setOf() val hiddenUsers = getStringSet(PrefKeys.HIDDEN_USERS, emptySet()) ?: setOf()
val localRelays = gson.fromJson( val localRelays = gson.fromJson(
@@ -75,7 +210,8 @@ class LocalPreferences(context: Context) {
val latestContactList = try { val latestContactList = try {
getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let { getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let {
Event.gson.fromJson(it, Event::class.java).getRefinedEvent(true) as ContactListEvent Event.gson.fromJson(it, Event::class.java)
.getRefinedEvent(true) as ContactListEvent
} }
} catch (e: Throwable) { } catch (e: Throwable) {
e.printStackTrace() e.printStackTrace()
@@ -84,43 +220,83 @@ class LocalPreferences(context: Context) {
val languagePreferences = try { val languagePreferences = try {
getString(PrefKeys.LANGUAGE_PREFS, null)?.let { getString(PrefKeys.LANGUAGE_PREFS, null)?.let {
gson.fromJson(it, object : TypeToken<Map<String, String>>() {}.type) as Map<String, String> gson.fromJson(
} ?: mapOf<String, String>() it,
object : TypeToken<Map<String, String>>() {}.type
) as Map<String, String>
} ?: mapOf()
} catch (e: Throwable) { } catch (e: Throwable) {
e.printStackTrace() e.printStackTrace()
mapOf<String, String>() mapOf()
} }
val hideDeleteRequestInfo = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, false) val hideDeleteRequestInfo = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, false)
if (pubKey != null) { return Account(
return Account( Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()), followingChannels,
followingChannels, hiddenUsers,
hiddenUsers, localRelays,
localRelays, dontTranslateFrom,
dontTranslateFrom, languagePreferences,
languagePreferences, translateTo,
translateTo, zapAmountChoices,
zapAmountChoices, hideDeleteRequestInfo,
hideDeleteRequestInfo, latestContactList
latestContactList )
)
} else {
return null
}
} }
} }
fun saveLastRead(route: String, timestampInSecs: Long) { fun saveLastRead(route: String, timestampInSecs: Long) {
encryptedPreferences.edit().apply { encryptedPreferences(currentAccount).edit().apply {
putLong(PrefKeys.LAST_READ(route), timestampInSecs) putLong(PrefKeys.LAST_READ(route), timestampInSecs)
}.apply() }.apply()
} }
fun loadLastRead(route: String): Long { fun loadLastRead(route: String): Long {
encryptedPreferences.run { encryptedPreferences(currentAccount).run {
return getLong(PrefKeys.LAST_READ(route), 0) return getLong(PrefKeys.LAST_READ(route), 0)
} }
} }
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
}
} }

View File

@@ -21,7 +21,7 @@ object NotificationCache {
val scope = CoroutineScope(Job() + Dispatchers.IO) val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch { scope.launch {
LocalPreferences(context).saveLastRead(route, timestampInSecs) LocalPreferences.saveLastRead(route, timestampInSecs)
live.invalidateData() live.invalidateData()
} }
} }
@@ -30,7 +30,7 @@ object NotificationCache {
fun load(route: String, context: Context): Long { fun load(route: String, context: Context): Long {
var lastTime = lastReadByRoute[route] var lastTime = lastReadByRoute[route]
if (lastTime == null) { if (lastTime == null) {
lastTime = LocalPreferences(context).loadLastRead(route) lastTime = LocalPreferences.loadLastRead(route)
lastReadByRoute[route] = lastTime lastReadByRoute[route] = lastTime
} }
return lastTime return lastTime

View File

@@ -49,12 +49,14 @@ class MainActivity : FragmentActivity() {
.build() .build()
} }
LocalPreferences.migrateSingleUserPrefs()
setContent { setContent {
AmethystTheme { AmethystTheme {
// A surface container using the 'background' color from the theme // A surface container using the 'background' color from the theme
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
val accountStateViewModel: AccountStateViewModel = viewModel { val accountStateViewModel: AccountStateViewModel = viewModel {
AccountStateViewModel(LocalPreferences(applicationContext)) AccountStateViewModel()
} }
AccountScreen(accountStateViewModel, startingPage) AccountScreen(accountStateViewModel, startingPage)

View File

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

View File

@@ -16,9 +16,11 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Divider import androidx.compose.material.Divider
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Icon import androidx.compose.material.Icon
import androidx.compose.material.IconButton import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.ModalBottomSheetState
import androidx.compose.material.ScaffoldState import androidx.compose.material.ScaffoldState
import androidx.compose.material.Surface import androidx.compose.material.Surface
import androidx.compose.material.Text 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.model.User
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.ResizeImage import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
fun DrawerContent( fun DrawerContent(
navController: NavHostController, navController: NavHostController,
scaffoldState: ScaffoldState, scaffoldState: ScaffoldState,
accountViewModel: AccountViewModel, sheetState: ModalBottomSheetState,
accountStateViewModel: AccountStateViewModel accountViewModel: AccountViewModel
) { ) {
val accountState by accountViewModel.accountLiveData.observeAsState() val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return val account = accountState?.account ?: return
@@ -88,10 +90,10 @@ fun DrawerContent(
account.userProfile(), account.userProfile(),
navController, navController,
scaffoldState, scaffoldState,
sheetState,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1F), .weight(1f),
accountStateViewModel,
account account
) )
@@ -214,15 +216,17 @@ fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffol
} }
} }
@OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
fun ListContent( fun ListContent(
accountUser: User?, accountUser: User?,
navController: NavHostController, navController: NavHostController,
scaffoldState: ScaffoldState, scaffoldState: ScaffoldState,
sheetState: ModalBottomSheetState,
modifier: Modifier, modifier: Modifier,
accountViewModel: AccountStateViewModel,
account: Account account: Account
) { ) {
val coroutineScope = rememberCoroutineScope()
var backupDialogOpen by remember { mutableStateOf(false) } var backupDialogOpen by remember { mutableStateOf(false) }
Column(modifier = modifier.fillMaxHeight()) { Column(modifier = modifier.fillMaxHeight()) {
@@ -260,10 +264,10 @@ fun ListContent(
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
IconRow( IconRow(
stringResource(R.string.log_out), title = stringResource(R.string.drawer_accounts),
R.drawable.ic_logout, icon = R.drawable.manage_accounts,
MaterialTheme.colors.onBackground, tint = MaterialTheme.colors.onBackground,
onClick = { accountViewModel.logOff() } onClick = { coroutineScope.launch { sheetState.show() } }
) )
} }

View File

@@ -36,62 +36,95 @@ sealed class Route(
val buildScreen: (AccountViewModel, AccountStateViewModel, NavController) -> @Composable (NavBackStackEntry) -> Unit val buildScreen: (AccountViewModel, AccountStateViewModel, NavController) -> @Composable (NavBackStackEntry) -> Unit
) { ) {
object Home : Route( object Home : Route(
"Home", route = "Home",
R.drawable.ic_home, icon = R.drawable.ic_home,
hasNewItems = { acc, cache, ctx -> homeHasNewItems(acc, cache, ctx) }, hasNewItems = { accountViewModel, cache, context ->
buildScreen = { acc, accSt, nav -> { _ -> HomeScreen(acc, nav) } } homeHasNewItems(accountViewModel, cache, context)
},
buildScreen = { accountViewModel, _, navController ->
{ HomeScreen(accountViewModel, navController) }
}
) )
object Search : Route( object Search : Route(
"Search", route = "Search",
R.drawable.ic_globe, icon = R.drawable.ic_globe,
buildScreen = { acc, accSt, nav -> { _ -> SearchScreen(acc, nav) } } buildScreen = { accountViewModel, _, navController ->
{ SearchScreen(accountViewModel, navController) }
}
) )
object Notification : Route( object Notification : Route(
"Notification", route = "Notification",
R.drawable.ic_notifications, icon = R.drawable.ic_notifications,
hasNewItems = { acc, cache, ctx -> notificationHasNewItems(acc, cache, ctx) }, hasNewItems = { accountViewModel, cache, context ->
buildScreen = { acc, accSt, nav -> { _ -> NotificationScreen(acc, nav) } } notificationHasNewItems(accountViewModel, cache, context)
},
buildScreen = { accountViewModel, _, navController ->
{ NotificationScreen(accountViewModel, navController) }
}
) )
object Message : Route( object Message : Route(
"Message", route = "Message",
R.drawable.ic_dm, icon = R.drawable.ic_dm,
hasNewItems = { acc, cache, ctx -> messagesHasNewItems(acc, cache, ctx) }, hasNewItems = { accountViewModel, cache, context ->
buildScreen = { acc, accSt, nav -> { _ -> ChatroomListScreen(acc, nav) } } messagesHasNewItems(accountViewModel, cache, context)
},
buildScreen = { accountViewModel, _, navController ->
{ ChatroomListScreen(accountViewModel, navController) }
}
) )
object Filters : Route( object Filters : Route(
"Filters", route = "Filters",
R.drawable.ic_security, icon = R.drawable.ic_security,
buildScreen = { acc, accSt, nav -> { _ -> FiltersScreen(acc, nav) } } buildScreen = { accountViewModel, _, navController ->
{ FiltersScreen(accountViewModel, navController) }
}
) )
object Profile : Route( object Profile : Route(
"User/{id}", route = "User/{id}",
R.drawable.ic_profile, icon = R.drawable.ic_profile,
arguments = listOf(navArgument("id") { type = NavType.StringType }), 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( object Note : Route(
"Note/{id}", route = "Note/{id}",
R.drawable.ic_moments, icon = R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType }), 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( object Room : Route(
"Room/{id}", route = "Room/{id}",
R.drawable.ic_moments, icon = R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType }), 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( object Channel : Route(
"Channel/{id}", route = "Channel/{id}",
R.drawable.ic_moments, icon = R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType }), 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 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) val lastTime = cache.load("Notification", context)
NotificationFeedFilter.account = account 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 ChatroomListKnownFeedFilter.account = account
val note = ChatroomListKnownFeedFilter.feed().firstOrNull { val note = ChatroomListKnownFeedFilter.feed().firstOrNull {

View File

@@ -8,6 +8,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.MainScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.MainScreen
import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage
@Composable @Composable
fun AccountScreen(accountStateViewModel: AccountStateViewModel, startingPage: String?) { fun AccountScreen(accountStateViewModel: AccountStateViewModel, startingPage: String?) {
@@ -17,7 +18,7 @@ fun AccountScreen(accountStateViewModel: AccountStateViewModel, startingPage: St
Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state -> Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) { when (state) {
is AccountState.LoggedOff -> { is AccountState.LoggedOff -> {
LoginPage(accountStateViewModel) LoginPage(accountStateViewModel, isFirstLogin = true)
} }
is AccountState.LoggedIn -> { is AccountState.LoggedIn -> {
MainScreen(AccountViewModel(state.account), accountStateViewModel, startingPage) MainScreen(AccountViewModel(state.account), accountStateViewModel, startingPage)

View File

@@ -17,7 +17,7 @@ import nostr.postr.Persona
import nostr.postr.bechToBytes import nostr.postr.bechToBytes
import java.util.regex.Pattern import java.util.regex.Pattern
class AccountStateViewModel(private val localPreferences: LocalPreferences) : ViewModel() { class AccountStateViewModel() : ViewModel() {
private val _accountContent = MutableStateFlow<AccountState>(AccountState.LoggedOff) private val _accountContent = MutableStateFlow<AccountState>(AccountState.LoggedOff)
val accountContent = _accountContent.asStateFlow() val accountContent = _accountContent.asStateFlow()
@@ -26,10 +26,14 @@ class AccountStateViewModel(private val localPreferences: LocalPreferences) : Vi
// Keeps it in the the UI thread to void blinking the login page. // Keeps it in the the UI thread to void blinking the login page.
// viewModelScope.launch(Dispatchers.IO) { // viewModelScope.launch(Dispatchers.IO) {
localPreferences.loadFromEncryptedStorage()?.let { tryLoginExistingAccount()
// }
}
private fun tryLoginExistingAccount() {
LocalPreferences.loadFromEncryptedStorage()?.let {
login(it) login(it)
} }
// }
} }
fun login(key: String) { fun login(key: String) {
@@ -47,18 +51,25 @@ class AccountStateViewModel(private val localPreferences: LocalPreferences) : Vi
Account(Persona(Hex.decode(key))) Account(Persona(Hex.decode(key)))
} }
localPreferences.saveToEncryptedStorage(account) LocalPreferences.updatePrefsForLogin(account)
login(account) login(account)
} }
fun switchUser(npub: String) {
prepareLogoutOrSwitch()
LocalPreferences.switchToAccount(npub)
tryLoginExistingAccount()
}
fun newKey() { fun newKey() {
val account = Account(Persona()) val account = Account(Persona())
localPreferences.saveToEncryptedStorage(account) LocalPreferences.updatePrefsForLogin(account)
login(account) login(account)
} }
fun login(account: Account) { fun login(account: Account) {
LocalPreferences.updatePrefsForLogin(account)
if (account.loggedIn.privKey != null) { if (account.loggedIn.privKey != null) {
_accountContent.update { AccountState.LoggedIn(account) } _accountContent.update { AccountState.LoggedIn(account) }
} else { } else {
@@ -77,11 +88,11 @@ class AccountStateViewModel(private val localPreferences: LocalPreferences) : Vi
private val saveListener: (com.vitorpamplona.amethyst.model.AccountState) -> Unit = { private val saveListener: (com.vitorpamplona.amethyst.model.AccountState) -> Unit = {
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
localPreferences.saveToEncryptedStorage(it.account) LocalPreferences.saveToEncryptedStorage(it.account)
} }
} }
fun logOff() { private fun prepareLogoutOrSwitch() {
val state = accountContent.value val state = accountContent.value
when (state) { when (state) {
@@ -99,7 +110,11 @@ class AccountStateViewModel(private val localPreferences: LocalPreferences) : Vi
} }
_accountContent.update { AccountState.LoggedOff } _accountContent.update { AccountState.LoggedOff }
}
localPreferences.clearEncryptedStorage() fun logOff(npub: String) {
prepareLogoutOrSwitch()
LocalPreferences.updatePrefsForLogout(npub)
tryLoginExistingAccount()
} }
} }

View File

@@ -78,7 +78,12 @@ import com.vitorpamplona.amethyst.ui.screen.ChatroomFeedView
import com.vitorpamplona.amethyst.ui.screen.NostrChannelFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrChannelFeedViewModel
@Composable @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 accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account val account = accountState?.account

View File

@@ -7,9 +7,13 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.DrawerValue import androidx.compose.material.DrawerValue
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.ModalBottomSheetLayout
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.Scaffold import androidx.compose.material.Scaffold
import androidx.compose.material.rememberDrawerState import androidx.compose.material.rememberDrawerState
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material.rememberScaffoldState import androidx.compose.material.rememberScaffoldState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -19,6 +23,7 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.vitorpamplona.amethyst.buttons.NewChannelButton import com.vitorpamplona.amethyst.buttons.NewChannelButton
import com.vitorpamplona.amethyst.buttons.NewNoteButton 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.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.AppNavigation import com.vitorpamplona.amethyst.ui.navigation.AppNavigation
import com.vitorpamplona.amethyst.ui.navigation.AppTopBar 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.AccountState
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
@OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel, startingPage: String? = null) { fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel, startingPage: String? = null) {
val navController = rememberNavController() val navController = rememberNavController()
val scaffoldState = rememberScaffoldState(rememberDrawerState(DrawerValue.Closed)) val scaffoldState = rememberScaffoldState(rememberDrawerState(DrawerValue.Closed))
val sheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden,
confirmValueChange = { it != ModalBottomSheetValue.HalfExpanded },
skipHalfExpanded = true
)
Scaffold( ModalBottomSheetLayout(
modifier = Modifier sheetState = sheetState,
.background(MaterialTheme.colors.primaryVariant) sheetContent = {
.statusBarsPadding(), AccountSwitchBottomSheet(accountViewModel = accountViewModel, accountStateViewModel = accountStateViewModel)
bottomBar = { }
AppBottomBar(navController, accountViewModel)
},
topBar = {
AppTopBar(navController, scaffoldState, accountViewModel)
},
drawerContent = {
DrawerContent(navController, scaffoldState, accountViewModel, accountStateViewModel)
},
floatingActionButton = {
FloatingButton(navController, accountStateViewModel)
},
scaffoldState = scaffoldState
) { ) {
Column(modifier = Modifier.padding(bottom = it.calculateBottomPadding())) { Scaffold(
AppNavigation(navController, accountViewModel, accountStateViewModel, startingPage) 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)
}
} }
} }
} }

View File

@@ -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.Image
import androidx.compose.foundation.layout.* 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import java.util.* import java.util.*
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
fun LoginPage(accountViewModel: AccountStateViewModel) { fun LoginPage(
accountViewModel: AccountStateViewModel,
isFirstLogin: Boolean
) {
val key = remember { mutableStateOf(TextFieldValue("")) } val key = remember { mutableStateOf(TextFieldValue("")) }
var errorMessage by remember { mutableStateOf("") } var errorMessage by remember { mutableStateOf("") }
val acceptedTerms = remember { mutableStateOf(false) } val acceptedTerms = remember { mutableStateOf(!isFirstLogin) }
var termsAcceptanceIsRequired by remember { mutableStateOf("") } var termsAcceptanceIsRequired by remember { mutableStateOf("") }
val uri = LocalUriHandler.current val uri = LocalUriHandler.current
val context = LocalContext.current val context = LocalContext.current
@@ -147,48 +151,50 @@ fun LoginPage(accountViewModel: AccountStateViewModel) {
Spacer(modifier = Modifier.height(20.dp)) Spacer(modifier = Modifier.height(20.dp))
Row(verticalAlignment = Alignment.CenterVertically) { if (isFirstLogin) {
Checkbox( Row(verticalAlignment = Alignment.CenterVertically) {
checked = acceptedTerms.value, Checkbox(
onCheckedChange = { acceptedTerms.value = it } checked = acceptedTerms.value,
) onCheckedChange = { acceptedTerms.value = it }
)
val regularText = val regularText =
SpanStyle(color = MaterialTheme.colors.onBackground) SpanStyle(color = MaterialTheme.colors.onBackground)
val clickableTextStyle = val clickableTextStyle =
SpanStyle(color = MaterialTheme.colors.primary) SpanStyle(color = MaterialTheme.colors.primary)
val annotatedTermsString = buildAnnotatedString { val annotatedTermsString = buildAnnotatedString {
withStyle(regularText) { withStyle(regularText) {
append(stringResource(R.string.i_accept_the)) 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") }
}
} }
}
}
if (termsAcceptanceIsRequired.isNotBlank()) { withStyle(clickableTextStyle) {
Text( pushStringAnnotation("openTerms", "")
text = termsAcceptanceIsRequired, append(stringResource(R.string.terms_of_use))
color = MaterialTheme.colors.error, }
style = MaterialTheme.typography.caption }
)
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)) Spacer(modifier = Modifier.height(20.dp))

View File

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

View File

@@ -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="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_delete_button">Delete</string>
<string name="quick_action_dont_show_again_button">Don\'t show again</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> </resources>