mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2025-09-27 05:17:26 +02:00
Merge pull request #263 from maxmoney21m/feature/multiple-accounts
Multiple account management
This commit is contained in:
@@ -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"
|
||||||
|
15
app/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt
Normal file
15
app/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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
|
||||||
|
@@ -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)
|
||||||
|
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -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() } }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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 {
|
||||||
|
@@ -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)
|
||||||
|
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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
|
||||||
|
|
||||||
|
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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))
|
||||||
|
7
app/src/main/res/drawable/manage_accounts.xml
Normal file
7
app/src/main/res/drawable/manage_accounts.xml
Normal 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>
|
@@ -220,5 +220,13 @@
|
|||||||
<string name="private_conversation_notification">"<Unable to decrypt private message>\n\nYou were cited in a private/encrypted conversation between %1$s and %2$s."</string>
|
<string name="private_conversation_notification">"<Unable to decrypt private message>\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>
|
||||||
|
Reference in New Issue
Block a user