Use multiple preference files for different accounts
This commit is contained in:
Generated
+17
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="deploymentTargetDropDown">
|
||||||
|
<targetSelectedWithDropDown>
|
||||||
|
<Target>
|
||||||
|
<type value="QUICK_BOOT_TARGET" />
|
||||||
|
<deviceKey>
|
||||||
|
<Key>
|
||||||
|
<type value="VIRTUAL_DEVICE_PATH" />
|
||||||
|
<value value="C:\Users\aiong\.android\avd\Pixel_6_API_33.avd" />
|
||||||
|
</Key>
|
||||||
|
</deviceKey>
|
||||||
|
</Target>
|
||||||
|
</targetSelectedWithDropDown>
|
||||||
|
<timeTargetWasSelectedWithDropDown value="2023-03-11T16:38:31.686457400Z" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,22 @@
|
|||||||
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 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 = if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$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,7 @@
|
|||||||
package com.vitorpamplona.amethyst
|
package com.vitorpamplona.amethyst
|
||||||
|
|
||||||
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
|
||||||
@@ -11,53 +12,77 @@ 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 nostr.postr.Persona
|
import nostr.postr.Persona
|
||||||
import nostr.postr.toHex
|
import nostr.postr.toHex
|
||||||
import nostr.postr.toNpub
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
|
const val DEBUG_PLAINTEXT_PREFERENCES = true
|
||||||
|
|
||||||
data class AccountInfo(val npub: String, val current: Boolean, val displayName: String?, val profilePicture: String?)
|
data class AccountInfo(val npub: String, val current: Boolean, val displayName: String?, val profilePicture: String?)
|
||||||
|
|
||||||
class LocalPreferences(context: Context) {
|
private object PrefKeys {
|
||||||
|
const val CURRENT_ACCOUNT = "currently_logged_in_account"
|
||||||
|
const val SAVED_ACCOUNTS = "all_saved_accounts"
|
||||||
|
const val NOSTR_PRIVKEY = "nostr_privkey"
|
||||||
|
const val NOSTR_PUBKEY = "nostr_pubkey"
|
||||||
|
const val DISPLAY_NAME = "display_name"
|
||||||
|
const val PROFILE_PICTURE_URL = "profile_picture"
|
||||||
|
const val FOLLOWING_CHANNELS = "following_channels"
|
||||||
|
const val HIDDEN_USERS = "hidden_users"
|
||||||
|
const val RELAYS = "relays"
|
||||||
|
const val DONT_TRANSLATE_FROM = "dontTranslateFrom"
|
||||||
|
const val LANGUAGE_PREFS = "languagePreferences"
|
||||||
|
const val TRANSLATE_TO = "translateTo"
|
||||||
|
const val ZAP_AMOUNTS = "zapAmounts"
|
||||||
|
const val LATEST_CONTACT_LIST = "latestContactList"
|
||||||
|
const val HIDE_DELETE_REQUEST_INFO = "hideDeleteRequestInfo"
|
||||||
|
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
|
||||||
|
}
|
||||||
|
|
||||||
private fun prefKeysForAccount(npub: String) = object {
|
private val gson = GsonBuilder().create()
|
||||||
val NOSTR_PRIVKEY = "$npub/nostr_privkey"
|
|
||||||
val NOSTR_PUBKEY = "$npub/nostr_pubkey"
|
object LocalPreferences {
|
||||||
val DISPLAY_NAME = "$npub/display_name"
|
private var currentAccount: String?
|
||||||
val PROFILE_PICTURE_URL = "$npub/profile_picture"
|
get() = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
|
||||||
val FOLLOWING_CHANNELS = "$npub/following_channels"
|
set(npub) {
|
||||||
val HIDDEN_USERS = "$npub/hidden_users"
|
val prefs = encryptedPreferences()
|
||||||
val RELAYS = "$npub/relays"
|
prefs.edit().apply {
|
||||||
val DONT_TRANSLATE_FROM = "$npub/dontTranslateFrom"
|
putString(PrefKeys.CURRENT_ACCOUNT, npub)
|
||||||
val LANGUAGE_PREFS = "$npub/languagePreferences"
|
}.apply()
|
||||||
val TRANSLATE_TO = "$npub/translateTo"
|
}
|
||||||
val ZAP_AMOUNTS = "$npub/zapAmounts"
|
|
||||||
val LATEST_CONTACT_LIST = "$npub/latestContactList"
|
private val savedAccounts: Set<String>
|
||||||
val HIDE_DELETE_REQUEST_INFO = "$npub/hideDeleteRequestInfo"
|
get() = encryptedPreferences().getStringSet(PrefKeys.SAVED_ACCOUNTS, null) ?: setOf()
|
||||||
// val LAST_READ: (String) -> String = { route -> "$npub/last_read_route_$route" }
|
|
||||||
|
private fun addAccount(npub: String) {
|
||||||
|
val accounts = savedAccounts.toMutableSet()
|
||||||
|
accounts.add(npub)
|
||||||
|
val prefs = encryptedPreferences()
|
||||||
|
prefs.edit().apply {
|
||||||
|
putStringSet(PrefKeys.SAVED_ACCOUNTS, accounts)
|
||||||
|
}.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
private object PrefKeys {
|
private fun removeAccount(npub: String) {
|
||||||
const val CURRENT_ACCOUNT = "currentlyLoggedInAccount"
|
val accounts = savedAccounts.toMutableSet()
|
||||||
|
accounts.remove(npub)
|
||||||
// val NOSTR_PRIVKEY = "nostr_privkey"
|
val prefs = encryptedPreferences()
|
||||||
// val NOSTR_PUBKEY = "nostr_pubkey"
|
prefs.edit().apply {
|
||||||
// val FOLLOWING_CHANNELS = "following_channels"
|
putStringSet(PrefKeys.SAVED_ACCOUNTS, accounts)
|
||||||
// val HIDDEN_USERS = "hidden_users"
|
}.apply()
|
||||||
// val RELAYS = "relays"
|
|
||||||
// val DONT_TRANSLATE_FROM = "dontTranslateFrom"
|
|
||||||
// val LANGUAGE_PREFS = "languagePreferences"
|
|
||||||
// val TRANSLATE_TO = "translateTo"
|
|
||||||
// val ZAP_AMOUNTS = "zapAmounts"
|
|
||||||
// val LATEST_CONTACT_LIST = "latestContactList"
|
|
||||||
// val HIDE_DELETE_REQUEST_INFO = "hideDeleteRequestInfo"
|
|
||||||
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val encryptedPreferences = EncryptedStorage.preferences(context)
|
private fun encryptedPreferences(npub: String? = null): SharedPreferences {
|
||||||
private val gson = GsonBuilder().create()
|
return if (DEBUG_PLAINTEXT_PREFERENCES) {
|
||||||
|
val preferenceFile = if (npub == null) "testing_only" else "testing_only_$npub"
|
||||||
|
Amethyst.instance.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE)
|
||||||
|
} else {
|
||||||
|
return EncryptedStorage.preferences(npub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun clearEncryptedStorage() {
|
fun clearEncryptedStorage(npub: String? = null) {
|
||||||
encryptedPreferences.edit().apply {
|
val encPrefs = encryptedPreferences(npub)
|
||||||
encryptedPreferences.all.keys.forEach {
|
encPrefs.edit().apply {
|
||||||
|
encPrefs.all.keys.forEach {
|
||||||
remove(it)
|
remove(it)
|
||||||
}
|
}
|
||||||
// encryptedPreferences.all.keys.filter {
|
// encryptedPreferences.all.keys.filter {
|
||||||
@@ -69,76 +94,64 @@ class LocalPreferences(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun findAllLocalAccounts(): List<AccountInfo> {
|
fun findAllLocalAccounts(): List<AccountInfo> {
|
||||||
encryptedPreferences.apply {
|
return savedAccounts.map { npub ->
|
||||||
val currentAccount = getString(PrefKeys.CURRENT_ACCOUNT, null)
|
val prefs = encryptedPreferences(npub)
|
||||||
return encryptedPreferences.all.keys.filter {
|
|
||||||
it.endsWith("nostr_pubkey")
|
AccountInfo(
|
||||||
}.map {
|
npub = npub,
|
||||||
val npub = it.substringBefore("/")
|
current = npub == currentAccount,
|
||||||
val myPrefs = prefKeysForAccount(npub)
|
displayName = prefs.getString(PrefKeys.DISPLAY_NAME, null),
|
||||||
AccountInfo(
|
profilePicture = prefs.getString(PrefKeys.PROFILE_PICTURE_URL, null)
|
||||||
npub,
|
)
|
||||||
npub == currentAccount,
|
|
||||||
getString(myPrefs.DISPLAY_NAME, null),
|
|
||||||
getString(myPrefs.PROFILE_PICTURE_URL, null)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveToEncryptedStorage(account: Account) {
|
fun setCurrentAccount(account: Account) {
|
||||||
val npub = account.loggedIn.pubKey.toNpub()
|
val npub = account.userProfile().pubkeyNpub()
|
||||||
val myPrefs = prefKeysForAccount(npub)
|
currentAccount = npub
|
||||||
|
addAccount(npub)
|
||||||
encryptedPreferences.edit().apply {
|
|
||||||
putString(PrefKeys.CURRENT_ACCOUNT, npub)
|
|
||||||
account.loggedIn.privKey?.let { putString(myPrefs.NOSTR_PRIVKEY, it.toHex()) }
|
|
||||||
account.loggedIn.pubKey.let { putString(myPrefs.NOSTR_PUBKEY, it.toHex()) }
|
|
||||||
putStringSet(myPrefs.FOLLOWING_CHANNELS, account.followingChannels)
|
|
||||||
putStringSet(myPrefs.HIDDEN_USERS, account.hiddenUsers)
|
|
||||||
putString(myPrefs.RELAYS, gson.toJson(account.localRelays))
|
|
||||||
putStringSet(myPrefs.DONT_TRANSLATE_FROM, account.dontTranslateFrom)
|
|
||||||
putString(myPrefs.LANGUAGE_PREFS, gson.toJson(account.languagePreferences))
|
|
||||||
putString(myPrefs.TRANSLATE_TO, account.translateTo)
|
|
||||||
putString(myPrefs.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices))
|
|
||||||
putString(myPrefs.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
|
|
||||||
putBoolean(myPrefs.HIDE_DELETE_REQUEST_INFO, account.hideDeleteRequestInfo)
|
|
||||||
}.apply()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveCurrentAccountMetadata(account: Account) {
|
fun saveToEncryptedStorage(account: Account) {
|
||||||
val myPrefs = prefKeysForAccount(account.loggedIn.pubKey.toNpub())
|
val prefs = encryptedPreferences(account.userProfile().pubkeyNpub())
|
||||||
|
prefs.edit().apply {
|
||||||
encryptedPreferences.edit().apply {
|
account.loggedIn.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHex()) }
|
||||||
putString(myPrefs.DISPLAY_NAME, account.userProfile().toBestDisplayName())
|
account.loggedIn.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHex()) }
|
||||||
putString(myPrefs.PROFILE_PICTURE_URL, account.userProfile().profilePicture())
|
putStringSet(PrefKeys.FOLLOWING_CHANNELS, account.followingChannels)
|
||||||
|
putStringSet(PrefKeys.HIDDEN_USERS, account.hiddenUsers)
|
||||||
|
putString(PrefKeys.RELAYS, gson.toJson(account.localRelays))
|
||||||
|
putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom)
|
||||||
|
putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(account.languagePreferences))
|
||||||
|
putString(PrefKeys.TRANSLATE_TO, account.translateTo)
|
||||||
|
putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices))
|
||||||
|
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
|
||||||
|
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, account.hideDeleteRequestInfo)
|
||||||
|
putString(PrefKeys.DISPLAY_NAME, account.userProfile().toBestDisplayName())
|
||||||
|
putString(PrefKeys.PROFILE_PICTURE_URL, account.userProfile().profilePicture())
|
||||||
}.apply()
|
}.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadFromEncryptedStorage(): Account? {
|
fun loadFromEncryptedStorage(): Account? {
|
||||||
encryptedPreferences.apply {
|
encryptedPreferences(currentAccount).apply {
|
||||||
val npub = getString(PrefKeys.CURRENT_ACCOUNT, null) ?: return null
|
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null
|
||||||
val myPrefs = prefKeysForAccount(npub)
|
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
|
||||||
|
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
|
||||||
val pubKey = getString(myPrefs.NOSTR_PUBKEY, null) ?: return null
|
val hiddenUsers = getStringSet(PrefKeys.HIDDEN_USERS, emptySet()) ?: setOf()
|
||||||
val privKey = getString(myPrefs.NOSTR_PRIVKEY, null)
|
|
||||||
val followingChannels = getStringSet(myPrefs.FOLLOWING_CHANNELS, null) ?: setOf()
|
|
||||||
val hiddenUsers = getStringSet(myPrefs.HIDDEN_USERS, emptySet()) ?: setOf()
|
|
||||||
val localRelays = gson.fromJson(
|
val localRelays = gson.fromJson(
|
||||||
getString(myPrefs.RELAYS, "[]"),
|
getString(PrefKeys.RELAYS, "[]"),
|
||||||
object : TypeToken<Set<RelaySetupInfo>>() {}.type
|
object : TypeToken<Set<RelaySetupInfo>>() {}.type
|
||||||
) ?: setOf<RelaySetupInfo>()
|
) ?: setOf<RelaySetupInfo>()
|
||||||
|
|
||||||
val dontTranslateFrom = getStringSet(myPrefs.DONT_TRANSLATE_FROM, null) ?: setOf()
|
val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
|
||||||
val translateTo = getString(myPrefs.TRANSLATE_TO, null) ?: Locale.getDefault().language
|
val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
|
||||||
|
|
||||||
val zapAmountChoices = gson.fromJson(
|
val zapAmountChoices = gson.fromJson(
|
||||||
getString(myPrefs.ZAP_AMOUNTS, "[]"),
|
getString(PrefKeys.ZAP_AMOUNTS, "[]"),
|
||||||
object : TypeToken<List<Long>>() {}.type
|
object : TypeToken<List<Long>>() {}.type
|
||||||
) ?: listOf(500L, 1000L, 5000L)
|
) ?: listOf(500L, 1000L, 5000L)
|
||||||
|
|
||||||
val latestContactList = try {
|
val latestContactList = try {
|
||||||
getString(myPrefs.LATEST_CONTACT_LIST, null)?.let {
|
getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let {
|
||||||
Event.gson.fromJson(it, Event::class.java).getRefinedEvent(true) as ContactListEvent
|
Event.gson.fromJson(it, Event::class.java).getRefinedEvent(true) as ContactListEvent
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
@@ -147,7 +160,7 @@ class LocalPreferences(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val languagePreferences = try {
|
val languagePreferences = try {
|
||||||
getString(myPrefs.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(it, object : TypeToken<Map<String, String>>() {}.type) as Map<String, String>
|
||||||
} ?: mapOf()
|
} ?: mapOf()
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
@@ -155,7 +168,7 @@ class LocalPreferences(context: Context) {
|
|||||||
mapOf()
|
mapOf()
|
||||||
}
|
}
|
||||||
|
|
||||||
val hideDeleteRequestInfo = getBoolean(myPrefs.HIDE_DELETE_REQUEST_INFO, false)
|
val hideDeleteRequestInfo = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, false)
|
||||||
|
|
||||||
return Account(
|
return Account(
|
||||||
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
|
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
|
||||||
@@ -173,13 +186,13 @@ class LocalPreferences(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import coil.ImageLoader
|
|||||||
import coil.decode.GifDecoder
|
import coil.decode.GifDecoder
|
||||||
import coil.decode.ImageDecoderDecoder
|
import coil.decode.ImageDecoderDecoder
|
||||||
import coil.decode.SvgDecoder
|
import coil.decode.SvgDecoder
|
||||||
import com.vitorpamplona.amethyst.LocalPreferences
|
|
||||||
import com.vitorpamplona.amethyst.ServiceManager
|
import com.vitorpamplona.amethyst.ServiceManager
|
||||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||||
import com.vitorpamplona.amethyst.service.relays.Client
|
import com.vitorpamplona.amethyst.service.relays.Client
|
||||||
@@ -54,7 +53,7 @@ class MainActivity : FragmentActivity() {
|
|||||||
// 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)
|
||||||
|
|||||||
+123
-9
@@ -1,54 +1,81 @@
|
|||||||
package com.vitorpamplona.amethyst.ui.navigation
|
package com.vitorpamplona.amethyst.ui.navigation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
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.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.ExperimentalMaterialApi
|
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.ModalBottomSheetState
|
import androidx.compose.material.ModalBottomSheetState
|
||||||
|
import androidx.compose.material.OutlinedTextField
|
||||||
|
import androidx.compose.material.Surface
|
||||||
import androidx.compose.material.Text
|
import androidx.compose.material.Text
|
||||||
import androidx.compose.material.TextButton
|
import androidx.compose.material.TextButton
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Logout
|
import androidx.compose.material.icons.filled.Logout
|
||||||
|
import androidx.compose.material.icons.outlined.Visibility
|
||||||
|
import androidx.compose.material.icons.outlined.VisibilityOff
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.autofill.AutofillNode
|
||||||
|
import androidx.compose.ui.autofill.AutofillType
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||||
|
import androidx.compose.ui.layout.boundsInWindow
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.platform.LocalAutofill
|
||||||
|
import androidx.compose.ui.platform.LocalAutofillTree
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import com.vitorpamplona.amethyst.LocalPreferences
|
import com.vitorpamplona.amethyst.LocalPreferences
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.RoboHashCache
|
import com.vitorpamplona.amethyst.RoboHashCache
|
||||||
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.note.toShortenHex
|
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.loggedIn.AccountViewModel
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterialApi::class)
|
@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AccountSwitchBottomSheet(
|
fun AccountSwitchBottomSheet(
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
|
accountStateViewModel: AccountStateViewModel,
|
||||||
sheetState: ModalBottomSheetState
|
sheetState: ModalBottomSheetState
|
||||||
) {
|
) {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val localPrefs = LocalPreferences(context)
|
val accounts = LocalPreferences.findAllLocalAccounts()
|
||||||
val accounts = localPrefs.findAllLocalAccounts()
|
|
||||||
|
|
||||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||||
val account = accountState?.account ?: return
|
val account = accountState?.account ?: return
|
||||||
@@ -56,9 +83,7 @@ fun AccountSwitchBottomSheet(
|
|||||||
val accountUserState by account.userProfile().live().metadata.observeAsState()
|
val accountUserState by account.userProfile().live().metadata.observeAsState()
|
||||||
val accountUser = accountUserState?.user ?: return
|
val accountUser = accountUserState?.user ?: return
|
||||||
|
|
||||||
LaunchedEffect(key1 = accountUser) {
|
var popupExpanded by remember { mutableStateOf(false) }
|
||||||
localPrefs.saveCurrentAccountMetadata(account)
|
|
||||||
}
|
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
Row(
|
Row(
|
||||||
@@ -114,9 +139,98 @@ fun AccountSwitchBottomSheet(
|
|||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
TextButton(onClick = { coroutineScope.launch { sheetState.hide() } }) {
|
TextButton(onClick = { popupExpanded = true }) {
|
||||||
Text("Add New Account")
|
Text("Add New Account")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (popupExpanded) {
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = { popupExpanded = false },
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||||
|
) {
|
||||||
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.background(MaterialTheme.colors.surface),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
val key = remember { mutableStateOf(TextFieldValue("")) }
|
||||||
|
var errorMessage by remember { mutableStateOf("") }
|
||||||
|
var showPassword by remember {
|
||||||
|
mutableStateOf(false)
|
||||||
|
}
|
||||||
|
val autofillNode = AutofillNode(
|
||||||
|
autofillTypes = listOf(AutofillType.Password),
|
||||||
|
onFill = { key.value = TextFieldValue(it) }
|
||||||
|
)
|
||||||
|
val autofill = LocalAutofill.current
|
||||||
|
LocalAutofillTree.current += autofillNode
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
modifier = Modifier
|
||||||
|
.onGloballyPositioned { coordinates ->
|
||||||
|
autofillNode.boundingBox = coordinates.boundsInWindow()
|
||||||
|
}
|
||||||
|
.onFocusChanged { focusState ->
|
||||||
|
autofill?.run {
|
||||||
|
if (focusState.isFocused) {
|
||||||
|
requestAutofillForNode(autofillNode)
|
||||||
|
} else {
|
||||||
|
cancelAutofillForNode(autofillNode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
value = key.value,
|
||||||
|
onValueChange = { key.value = it },
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
autoCorrect = false,
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Go
|
||||||
|
),
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.nsec_npub_hex_private_key),
|
||||||
|
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { showPassword = !showPassword }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
|
||||||
|
contentDescription = if (showPassword) {
|
||||||
|
stringResource(R.string.show_password)
|
||||||
|
} else {
|
||||||
|
stringResource(
|
||||||
|
R.string.hide_password
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onGo = {
|
||||||
|
try {
|
||||||
|
accountStateViewModel.login(key.value.text)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
errorMessage = context.getString(R.string.invalid_key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (errorMessage.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = errorMessage,
|
||||||
|
color = MaterialTheme.colors.error,
|
||||||
|
style = MaterialTheme.typography.caption
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +26,7 @@ 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 {
|
LocalPreferences.loadFromEncryptedStorage()?.let {
|
||||||
login(it)
|
login(it)
|
||||||
}
|
}
|
||||||
// }
|
// }
|
||||||
@@ -47,18 +47,19 @@ class AccountStateViewModel(private val localPreferences: LocalPreferences) : Vi
|
|||||||
Account(Persona(Hex.decode(key)))
|
Account(Persona(Hex.decode(key)))
|
||||||
}
|
}
|
||||||
|
|
||||||
localPreferences.saveToEncryptedStorage(account)
|
LocalPreferences.saveToEncryptedStorage(account)
|
||||||
|
|
||||||
login(account)
|
login(account)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun newKey() {
|
fun newKey() {
|
||||||
val account = Account(Persona())
|
val account = Account(Persona())
|
||||||
localPreferences.saveToEncryptedStorage(account)
|
LocalPreferences.saveToEncryptedStorage(account)
|
||||||
login(account)
|
login(account)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun login(account: Account) {
|
fun login(account: Account) {
|
||||||
|
LocalPreferences.setCurrentAccount(account)
|
||||||
|
|
||||||
if (account.loggedIn.privKey != null) {
|
if (account.loggedIn.privKey != null) {
|
||||||
_accountContent.update { AccountState.LoggedIn(account) }
|
_accountContent.update { AccountState.LoggedIn(account) }
|
||||||
} else {
|
} else {
|
||||||
@@ -77,7 +78,7 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +101,6 @@ class AccountStateViewModel(private val localPreferences: LocalPreferences) : Vi
|
|||||||
|
|
||||||
_accountContent.update { AccountState.LoggedOff }
|
_accountContent.update { AccountState.LoggedOff }
|
||||||
|
|
||||||
localPreferences.clearEncryptedStorage()
|
LocalPreferences.clearEncryptedStorage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
|
|||||||
ModalBottomSheetLayout(
|
ModalBottomSheetLayout(
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
sheetContent = {
|
sheetContent = {
|
||||||
AccountSwitchBottomSheet(accountViewModel = accountViewModel, sheetState = sheetState)
|
AccountSwitchBottomSheet(accountViewModel = accountViewModel, accountStateViewModel = accountStateViewModel, sheetState = sheetState)
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
|||||||
Reference in New Issue
Block a user