diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index 58102bb98..4aae1b70a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -26,7 +26,10 @@ import android.util.Log import androidx.security.crypto.EncryptedSharedPreferences import coil3.disk.DiskCache import coil3.memory.MemoryCache +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager import com.vitorpamplona.amethyst.service.crashreports.CrashReportCache import com.vitorpamplona.amethyst.service.crashreports.UnexpectedCrashSaver @@ -49,10 +52,15 @@ import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.Notify import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav.scope import com.vitorpamplona.amethyst.ui.tor.TorManager +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache import com.vitorpamplona.quartz.nip03Timestamp.ots.okhttp.OtsBlockHeightCache +import com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -153,6 +161,15 @@ class Amethyst : Application() { // Coordinates all subscriptions for the Nostr Client val sources: RelaySubscriptionsCoordinator = RelaySubscriptionsCoordinator(LocalCache, client, applicationIOScope) + val accountsCache = + AccountCacheState( + geolocationFlow = locationManager.geohashStateFlow, + nwcFilterAssembler = sources.nwc, + cache = cache, + client = client, + scope = applicationIOScope, + ) + // saves the .content of NIP-95 blobs in disk to save memory val nip95cache: File by lazy { Nip95CacheFactory.new(this) } @@ -225,6 +242,31 @@ class Amethyst : Application() { } } + fun loadAccount(accountSettings: AccountSettings): Account { + val keyPair = accountSettings.keyPair + return accountsCache.loadAccount( + signer = + if (keyPair.privKey != null) { + NostrSignerInternal(keyPair) + } else { + when (val packageName = accountSettings.externalSignerPackageName) { + null -> NostrSignerInternal(keyPair) + else -> + NostrSignerExternal( + pubKey = keyPair.pubKey.toHexKey(), + packageName = packageName, + contentResolver = contentResolver, + ) + } + }, + accountSettings = accountSettings, + ) + } + + fun removeAccount(pubkey: HexKey) { + accountsCache.removeAccount(pubkey) + } + companion object { lateinit var instance: Amethyst private set diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 47a626522..92fee1c86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.model -import android.content.ContentResolver import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName @@ -29,10 +28,8 @@ import com.vitorpamplona.amethyst.ui.tor.TorSettings import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent @@ -49,7 +46,6 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.Permission -import com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo @@ -154,21 +150,6 @@ class AccountSettings( fun isWriteable(): Boolean = keyPair.privKey != null || externalSignerPackageName != null - fun createSigner(contentResolver: ContentResolver) = - if (keyPair.privKey != null) { - NostrSignerInternal(keyPair) - } else { - when (val packageName = externalSignerPackageName) { - null -> NostrSignerInternal(keyPair) - else -> - NostrSignerExternal( - pubKey = keyPair.pubKey.toHexKey(), - packageName = packageName, - contentResolver = contentResolver, - ) - } - } - // --- // Zaps and Reactions // --- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt new file mode 100644 index 000000000..a2ad9dcc2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -0,0 +1,65 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.accountsCache + +import androidx.collection.LruCache +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow + +class AccountCacheState( + val geolocationFlow: StateFlow, + val nwcFilterAssembler: NWCPaymentFilterAssembler, + val cache: LocalCache, + val client: INostrClient, + val scope: CoroutineScope, +) { + val accounts = LruCache(20) + + fun removeAccount(pubkey: HexKey) = accounts.remove(pubkey) + + fun loadAccount( + signer: NostrSigner, + accountSettings: AccountSettings, + ): Account { + val cached = accounts[signer.pubKey] + if (cached != null) return cached + + return Account( + settings = accountSettings, + signer = signer, + geolocationFlow = geolocationFlow, + nwcFilterAssembler = nwcFilterAssembler, + cache = cache, + client = client, + scope = scope, + ).also { + accounts.put(signer.pubKey, it) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 4b7723b71..8ac9e3179 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -24,6 +24,7 @@ import android.app.NotificationManager import android.content.Context import android.util.Log import androidx.core.content.ContextCompat +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.AccountSettings @@ -81,12 +82,11 @@ class EventNotificationConsumer( private suspend fun consumeIfMatchesAccount( pushWrappedEvent: GiftWrapEvent, - account: AccountSettings, + accountSettings: AccountSettings, ) { - val signer = account.createSigner(applicationContext.contentResolver) - - val notificationEvent = pushWrappedEvent.unwrapThrowing(signer) - consumeNotificationEvent(notificationEvent, signer, account) + val account = Amethyst.instance.loadAccount(accountSettings) + val notificationEvent = pushWrappedEvent.unwrapThrowing(account.signer) + consumeNotificationEvent(notificationEvent, account.signer, accountSettings) } suspend fun consumeNotificationEvent( @@ -124,11 +124,11 @@ class EventNotificationConsumer( var matchAccount = false LocalPreferences.allSavedAccounts().forEach { if (!matchAccount && (it.hasPrivKey || it.loggedInWithExternalSigner) && it.npub in npubs) { - LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)?.let { acc -> + LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)?.let { accountSettings -> Log.d(TAG, "New Notification Testing if for ${it.npub}") try { - val signer = acc.createSigner(applicationContext.contentResolver) - consumeNotificationEvent(event, signer, acc) + val account = Amethyst.instance.loadAccount(accountSettings) + consumeNotificationEvent(event, account.signer, accountSettings) matchAccount = true } catch (e: Exception) { if (e is CancellationException) throw e diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt index 43c2279b5..5c17122db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt @@ -59,8 +59,8 @@ class RegisterAccounts( } return mapNotNullAsync(remainingTos) { info -> - val signer = info.accountSettings.createSigner(Amethyst.instance.contentResolver) - RelayAuthEvent.create(info.relays, notificationToken, signer) + val account = Amethyst.instance.loadAccount(info.accountSettings) + RelayAuthEvent.create(info.relays, notificationToken, account.signer) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt index 0261ebd11..73041ccc0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt @@ -95,7 +95,7 @@ fun LoggedInSetup( ) { SetAccountCentricViewModelStore(state) { LoggedInPage( - state.accountSettings, + state.account, state.route, accountStateViewModel, sharedPreferencesViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt index 7976be8ef..08ec6c875 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt @@ -26,7 +26,7 @@ import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModelStore import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner -import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.navigation.routes.Route sealed class AccountState { @@ -36,7 +36,7 @@ sealed class AccountState { @Stable class LoggedIn( - val accountSettings: AccountSettings, + val account: Account, var route: Route? = null, ) : AccountState() { val currentViewModelStore = AccountCentricViewModelStore() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index 04c1c721a..956724057 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt @@ -184,7 +184,9 @@ class AccountStateViewModel : ViewModel() { accountSettings: AccountSettings, route: Route? = null, ) = withContext(Dispatchers.Main) { - _accountContent.update { AccountState.LoggedIn(accountSettings, route) } + _accountContent.update { + AccountState.LoggedIn(Amethyst.instance.loadAccount(accountSettings), route) + } collectorJob?.cancel() collectorJob = @@ -376,7 +378,8 @@ class AccountStateViewModel : ViewModel() { fun currentAccountNPub() = when (val state = _accountContent.value) { is AccountState.LoggedIn -> - state.accountSettings.keyPair.pubKey + state.account.signer.pubKey + .hexToByteArray() .toNpub() else -> null } @@ -387,10 +390,12 @@ class AccountStateViewModel : ViewModel() { // log off and relogin with the 0 account prepareLogoutOrSwitch() LocalPreferences.deleteAccount(accountInfo) + Amethyst.instance.removeAccount(accountInfo.npub.bechToBytes().toHexKey()) loginWithDefaultAccount() } else { // delete without switching logins LocalPreferences.deleteAccount(accountInfo) + Amethyst.instance.removeAccount(accountInfo.npub.bechToBytes().toHexKey()) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 64a04d086..14018abdb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -33,6 +33,7 @@ import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel import coil3.asDrawable import coil3.imageLoader import coil3.request.ImageRequest @@ -1725,9 +1726,9 @@ fun mockAccountViewModel(): AccountViewModel { ) return AccountViewModel( - sharedPreferencesViewModel.sharedPrefs, - Amethyst(), account = account, + settings = sharedPreferencesViewModel.sharedPrefs, + app = Amethyst(), ).also { mockedCache = it } @@ -1765,9 +1766,9 @@ fun mockVitorAccountViewModel(): AccountViewModel { ) return AccountViewModel( - sharedPreferencesViewModel.sharedPrefs, - Amethyst(), account = account, + settings = sharedPreferencesViewModel.sharedPrefs, + app = Amethyst(), ).also { vitorCache = it } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt index 3762c4d27..3c50e37e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt @@ -42,7 +42,7 @@ import com.google.accompanist.permissions.rememberPermissionState import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayAuthSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssemblerSubscription @@ -59,7 +59,7 @@ import kotlinx.coroutines.launch @Composable fun LoggedInPage( - accountSettings: AccountSettings, + account: Account, route: Route?, accountStateViewModel: AccountStateViewModel, sharedPreferencesViewModel: SharedPreferencesViewModel, @@ -69,9 +69,9 @@ fun LoggedInPage( key = "AccountViewModel", factory = AccountViewModel.Factory( - accountSettings, - sharedPreferencesViewModel.sharedPrefs, - Amethyst.instance, + account = account, + settings = sharedPreferencesViewModel.sharedPrefs, + app = Amethyst.instance, ), )