From 51873efb2a6b745ba1156d8ea75b634538fd58c7 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Wed, 17 Jun 2026 17:10:53 +0300 Subject: [PATCH] Load the encrypted seed --- .../compose/database/dao/NostrDao.kt | 443 +++++++++--------- .../repository/DatabaseNostrRepository.kt | 14 +- .../network/relays/RelaysSocketManager.kt | 18 +- .../compose/repository/NostrRepository.kt | 14 +- .../ui/composable/CreateProfileScreen.kt | 12 +- .../compose/ui/composable/LoadingScreen.kt | 8 +- .../SovereignWalletStartupScreen.kt | 363 ++++++++++++++ .../ui/composable/navigation/AuxNavHost.kt | 160 ++++--- .../routes/AuthenticatedProfileRoute.kt | 7 + .../navigation/routes/LoadingRoute.kt | 4 +- .../routes/SocialPreconditionRoute.kt | 4 +- .../routes/SovereignWalletStartupRoute.kt | 6 + .../widgets/LoadingDataIndicator.kt | 19 +- .../widgets/wallet/WalletsSelector.kt | 165 +++++++ .../ui/view/model/CreateProfileViewModel.kt | 13 +- .../ui/view/model/NavigationViewModel.kt | 375 +++------------ .../compose/ui/view/model/NotaryViewModel.kt | 33 +- .../model/SovereignWalletStartupViewModel.kt | 92 ++++ .../ui/view/model/SovereignWalletViewModel.kt | 293 ++++++++++++ .../ui/view/model/SynchronizationViewModel.kt | 51 +- .../ui/view/state/NavigationUIState.kt | 5 +- .../ui/view/state/SovereignWalletUIState.kt | 17 + .../acinq/phoenix/managers/WalletManager.kt | 9 +- 23 files changed, 1473 insertions(+), 652 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/SovereignWalletStartupScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/navigation/routes/AuthenticatedProfileRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/navigation/routes/SovereignWalletStartupRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/wallet/WalletsSelector.kt create mode 100644 composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/SovereignWalletStartupViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/SovereignWalletViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/state/SovereignWalletUIState.kt diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/NostrDao.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/NostrDao.kt index 242ddae3..d7486def 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/NostrDao.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/NostrDao.kt @@ -59,7 +59,7 @@ abstract class NostrDao( unsignedNostrEvent: UnsignedNostrEvent, nostrEvent: NostrEvent, relayURLs: List, - walletManager: WalletManager + activeKeyPair: KeyPair ) { logger.i("Publish Nostr Event: $nostrEvent ($relayURLs)") // Update unsignedEvent with signedTime time... @@ -76,7 +76,7 @@ abstract class NostrDao( relayURL = relayURLs.first(), synchronizationRelayURLs = relayURLs, level = 0, - walletManager = walletManager + activeKeyPair = activeKeyPair ) relayURLs.forEach { relayURL -> @@ -96,7 +96,7 @@ abstract class NostrDao( relayURL: String, synchronizationRelayURLs: List, level: Int, - walletManager: WalletManager + activeKeyPair: KeyPair ) { val storedNostrEvent = database.nostrEventDao().getNostrEventById(nostrEvent.id) @@ -135,7 +135,7 @@ abstract class NostrDao( relayURL = relayURL, synchronizationRelayURLs = synchronizationRelayURLs, level = level, - walletManager = walletManager + activeKeyPair = activeKeyPair ) } @@ -144,7 +144,7 @@ abstract class NostrDao( relayURL: String, synchronizationRelayURLs: List, level: Int, - walletManager: WalletManager + activeKeyPair: KeyPair ) { val profilePublicKeysToSync = mutableMapOf>() val eventIdsToSync = mutableMapOf>() @@ -544,259 +544,246 @@ abstract class NostrDao( // } // } // } - - walletManager.keyManager.value?.let { keyManager -> - keyManager.nostrPrivateKey() - } - if (walletManager.isLoaded()) { - val nostrKey = walletManager.keyManager.value?.nostrPrivateKey()?.let { nostrPrivateKey -> - val activeKeyPair = KeyPair( - privKey = nostrPrivateKey.value.toByteArray() + giftWrapMessage.decryptGiftWrapSeal( + activeKeyPair + ).let { giftWrapSeal -> + if (giftWrapSeal == null) { + throw GiftWrapUnsealException("Failed to unseal ${nostrEvent.id} from the giftWrap") + } else { + database.giftWrapSealDao().upsert( + giftWrapSeal ) - giftWrapMessage.decryptGiftWrapSeal( - activeKeyPair - ).let { giftWrapSeal -> - if (giftWrapSeal == null) { - throw GiftWrapUnsealException("Failed to unseal ${nostrEvent.id} from the giftWrap") + giftWrapSeal.decryptGiftWrapPayload( + activeKeyPair.privKey!! + ).let { decryptedGiftWrapPayload -> + if (decryptedGiftWrapPayload == null) { + throw GiftWrapSealDecryptionException("Failed to decrypt sealed ${giftWrapSeal.id} payload") } else { - database.giftWrapSealDao().upsert( - giftWrapSeal + if (giftWrapSeal.publicKey.lowercase() != decryptedGiftWrapPayload.publicKey.lowercase()) { + val giftWrapImpersonation = GiftWrapImpersonationException( + "Seal pubkey (${giftWrapSeal.publicKey}) and payload pubkey (${decryptedGiftWrapPayload.publicKey}) needs to be the same for the giftWrap ${nostrEvent.id}" + ) + logger.e("Impersonation", giftWrapImpersonation) + throw giftWrapImpersonation // Once this is thrown the database transaction fails and nothing gets saved to the DB... + } + val result = database.giftWrapPayloadDao().upsert( + decryptedGiftWrapPayload ) - giftWrapSeal.decryptGiftWrapPayload( - activeKeyPair.privKey!! - ).let { decryptedGiftWrapPayload -> - if (decryptedGiftWrapPayload == null) { - throw GiftWrapSealDecryptionException("Failed to decrypt sealed ${giftWrapSeal.id} payload") - } else { - if (giftWrapSeal.publicKey.lowercase() != decryptedGiftWrapPayload.publicKey.lowercase()) { - val giftWrapImpersonation = GiftWrapImpersonationException( - "Seal pubkey (${giftWrapSeal.publicKey}) and payload pubkey (${decryptedGiftWrapPayload.publicKey}) needs to be the same for the giftWrap ${nostrEvent.id}" + val giftWrapPayload = if (result != -1L) { + decryptedGiftWrapPayload.copy( + id = result + ) + } else { + decryptedGiftWrapPayload + } + + val chatRoomId = + giftWrapPayload.aggregatedParticipantsPublicKey() + logger.d("ChatRoomId: $chatRoomId") + + if (chatRoomId != null) { + // Find or create chatRoom + val localChatRoom = database.chatRoomDao().findChatRoomById( + chatRoomId + ) + + if (localChatRoom == null) { + val userPublicKey = activeKeyPair.pubKey.toHex() + database.chatRoomDao().upsert( + ChatRoom( + id = chatRoomId, + userPublicKey = userPublicKey, + subject = decryptedGiftWrapPayload.parseSubject(), + createdAt = decryptedGiftWrapPayload.createdAt, + initialGiftWrapPayloadId = giftWrapPayload.id ) - logger.e("Impersonation", giftWrapImpersonation) - throw giftWrapImpersonation // Once this is thrown the database transaction fails and nothing gets saved to the DB... - } - val result = database.giftWrapPayloadDao().upsert( - decryptedGiftWrapPayload ) - val giftWrapPayload = if (result != -1L) { - decryptedGiftWrapPayload.copy( - id = result + // Add participants... + val participants = giftWrapPayload.participantPTags( + NormalizedRelayUrl(relayURL) // TODO: Get relayURL for publicKey profile... + ).map { + Participant( + participantPublicKey = it.pubKey, + chatRoomId = chatRoomId, + relayHint = it.relayHint?.url ) - } else { - decryptedGiftWrapPayload } - val chatRoomId = - giftWrapPayload.aggregatedParticipantsPublicKey() - logger.d("ChatRoomId: $chatRoomId") + // Sync missing participant profiles... + participants.forEach { participant -> + val giftWrapParticipantProfile = + database.profileDao() + .getProfileByPublicKey(participant.participantPublicKey) - if (chatRoomId != null) { - // Find or create chatRoom - val localChatRoom = database.chatRoomDao().findChatRoomById( - chatRoomId - ) - - if (localChatRoom == null) { - val userPublicKey = activeKeyPair.pubKey.toHex() - database.chatRoomDao().upsert( - ChatRoom( - id = chatRoomId, - userPublicKey = userPublicKey, - subject = decryptedGiftWrapPayload.parseSubject(), - createdAt = decryptedGiftWrapPayload.createdAt, - initialGiftWrapPayloadId = giftWrapPayload.id + if (giftWrapParticipantProfile == null) { + // Save a placeholder + database.profileDao().insertPlaceholderProfile( + Profile( + displayName = "LOADING...", + publicKey = participant.participantPublicKey, + createdAt = GENESIS_AT, + nostrEventId = nostrEvent.id, // Will get overwriting by sync, ) ) - // Add participants... - val participants = giftWrapPayload.participantPTags( - NormalizedRelayUrl(relayURL) // TODO: Get relayURL for publicKey profile... - ).map { - Participant( - participantPublicKey = it.pubKey, - chatRoomId = chatRoomId, - relayHint = it.relayHint?.url + val recommendRelayUrl = + giftWrapMessage.receiverRelayHit + + if (recommendRelayUrl != null) { + if (profilePublicKeysToSync[recommendRelayUrl] == null) { + profilePublicKeysToSync[recommendRelayUrl] = + mutableSetOf() + } + profilePublicKeysToSync[recommendRelayUrl]?.add( + participant.participantPublicKey ) - } - - // Sync missing participant profiles... - participants.forEach { participant -> - val giftWrapParticipantProfile = - database.profileDao() - .getProfileByPublicKey(participant.participantPublicKey) - - if (giftWrapParticipantProfile == null) { - // Save a placeholder - database.profileDao().insertPlaceholderProfile( - Profile( - displayName = "LOADING...", - publicKey = participant.participantPublicKey, - createdAt = GENESIS_AT, - nostrEventId = nostrEvent.id, // Will get overwriting by sync, - ) - ) - - val recommendRelayUrl = - giftWrapMessage.receiverRelayHit - - if (recommendRelayUrl != null) { - if (profilePublicKeysToSync[recommendRelayUrl] == null) { - profilePublicKeysToSync[recommendRelayUrl] = - mutableSetOf() - } - profilePublicKeysToSync[recommendRelayUrl]?.add( - participant.participantPublicKey - ) - } else { - if (profilePublicKeysToSync[relayURL] == null) { - profilePublicKeysToSync[relayURL] = - mutableSetOf() - } - profilePublicKeysToSync[relayURL]?.add( - participant.participantPublicKey - ) - } + } else { + if (profilePublicKeysToSync[relayURL] == null) { + profilePublicKeysToSync[relayURL] = + mutableSetOf() } - } - - database.participantDao().upsert( - participants - ) - - participants.filter { it.participantPublicKey != userPublicKey } - .forEach { participant -> - val publicKey = participant.participantPublicKey - - val chatMessageRelayListEvent = - database.nostrEventDao() - .getAuthoredNostrEvents( - kinds = arrayOf( - ChatMessageRelayListEvent.KIND - ), - authors = arrayOf( - publicKey - ), - since = GENESIS_AT, - limit = 5 - ).firstOrNull()?.let { nostrEvent -> - if (nostrEvent.kind != ChatMessageRelayListEvent.KIND) { - logger.e("getAuthoredNostrEvents returned invalid ChatMessageRelayListEvent for ${publicKey}: $nostrEvent") - null - } else { - ChatMessageRelayListEvent( - id = nostrEvent.id, - tags = nostrEvent.tags, - pubKey = nostrEvent.pubKey, - content = nostrEvent.content, - createdAt = nostrEvent.createdAt.epochSeconds, - sig = nostrEvent.sig - ) - } - } - - if (chatMessageRelayListEvent != null) { - // Sync messages from this relay that were sent by us - val synchronizationFilter = - SynchronizationFilter( - kinds = arrayOf( - GiftWrapEvent.KIND, - ), - authors = arrayOf( - userPublicKey - ), - tags = mapOf( - Pair( - "p", - listOf(participant.participantPublicKey) - ) - ), - limit = 50 - ) - database.negentropySynchronizeRequestDao() - .insert( - chatMessageRelayListEvent.relays() - .map { normalizedRelayUrl -> - NegentropySynchronizeRequest( - id = NegentropySynchronizeRequest.computeId( - relayURL = normalizedRelayUrl.url, - synchronizationFilter = synchronizationFilter - ), - purpose = "sent-messages", - synchronizationFilter = synchronizationFilter, - relayURL = normalizedRelayUrl.url, - level = 0 - ) - } - ) - } else { - logger.w("We don't have a chatMessageRelayListEvent for the pubkey $publicKey") - - // Sync ChatMessageRelayListEvent publicKey... - // TODO: Get relayHint form participant... - if (profilePublicKeysToSync.containsKey( - relayURL - ) - ) { - profilePublicKeysToSync[relayURL] = - mutableSetOf() - } - profilePublicKeysToSync[relayURL]?.add( - participant.participantPublicKey - ) - } - } - } else { - giftWrapPayload.parseSubject()?.let { subject -> - database.chatRoomDao().upsert( - localChatRoom.chatRoom.copy( - subject = subject, - updatedAt = giftWrapPayload.createdAt - ) + profilePublicKeysToSync[relayURL]?.add( + participant.participantPublicKey ) } } + } - val chatMessageId = database.chatMessageDao().upsert( - ChatMessage( - giftWrapPayloadId = giftWrapPayload.id, - senderPublicKey = giftWrapPayload.publicKey, - isUserMessage = activeKeyPair.pubKey.toHex() == giftWrapPayload.publicKey, - chatRoomId = chatRoomId, - createdAt = giftWrapPayload.createdAt, - content = giftWrapPayload.content, + database.participantDao().upsert( + participants + ) + + participants.filter { it.participantPublicKey != userPublicKey } + .forEach { participant -> + val publicKey = participant.participantPublicKey + + val chatMessageRelayListEvent = + database.nostrEventDao() + .getAuthoredNostrEvents( + kinds = arrayOf( + ChatMessageRelayListEvent.KIND + ), + authors = arrayOf( + publicKey + ), + since = GENESIS_AT, + limit = 5 + ).firstOrNull()?.let { nostrEvent -> + if (nostrEvent.kind != ChatMessageRelayListEvent.KIND) { + logger.e("getAuthoredNostrEvents returned invalid ChatMessageRelayListEvent for ${publicKey}: $nostrEvent") + null + } else { + ChatMessageRelayListEvent( + id = nostrEvent.id, + tags = nostrEvent.tags, + pubKey = nostrEvent.pubKey, + content = nostrEvent.content, + createdAt = nostrEvent.createdAt.epochSeconds, + sig = nostrEvent.sig + ) + } + } + + if (chatMessageRelayListEvent != null) { + // Sync messages from this relay that were sent by us + val synchronizationFilter = + SynchronizationFilter( + kinds = arrayOf( + GiftWrapEvent.KIND, + ), + authors = arrayOf( + userPublicKey + ), + tags = mapOf( + Pair( + "p", + listOf(participant.participantPublicKey) + ) + ), + limit = 50 + ) + database.negentropySynchronizeRequestDao() + .insert( + chatMessageRelayListEvent.relays() + .map { normalizedRelayUrl -> + NegentropySynchronizeRequest( + id = NegentropySynchronizeRequest.computeId( + relayURL = normalizedRelayUrl.url, + synchronizationFilter = synchronizationFilter + ), + purpose = "sent-messages", + synchronizationFilter = synchronizationFilter, + relayURL = normalizedRelayUrl.url, + level = 0 + ) + } + ) + } else { + logger.w("We don't have a chatMessageRelayListEvent for the pubkey $publicKey") + + // Sync ChatMessageRelayListEvent publicKey... + // TODO: Get relayHint form participant... + if (profilePublicKeysToSync.containsKey( + relayURL + ) + ) { + profilePublicKeysToSync[relayURL] = + mutableSetOf() + } + profilePublicKeysToSync[relayURL]?.add( + participant.participantPublicKey + ) + } + } + } else { + giftWrapPayload.parseSubject()?.let { subject -> + database.chatRoomDao().upsert( + localChatRoom.chatRoom.copy( + subject = subject, + updatedAt = giftWrapPayload.createdAt ) ) - - val broadcastNostrEventReceiptId = - database.broadcastNostrEventReceiptDao().upsert( - BroadcastNostrEventReceipt( - nostrEventId = nostrEvent.id, - isAccepted = true, - isSync = true, - relayURL = relayURL - ) - ) - - database.chatMessageBroadcastNostrEventReceiptRelationDao() - .upsert( - ChatMessageBroadcastNostrEventReceiptRelation( - broadcastNostrEventReceiptId = broadcastNostrEventReceiptId, - chatMessageId = chatMessageId - ) - ) - } else { - logger.w("Failed to setup chatRoom for message") } } + + val chatMessageId = database.chatMessageDao().upsert( + ChatMessage( + giftWrapPayloadId = giftWrapPayload.id, + senderPublicKey = giftWrapPayload.publicKey, + isUserMessage = activeKeyPair.pubKey.toHex() == giftWrapPayload.publicKey, + chatRoomId = chatRoomId, + createdAt = giftWrapPayload.createdAt, + content = giftWrapPayload.content, + ) + ) + + val broadcastNostrEventReceiptId = + database.broadcastNostrEventReceiptDao().upsert( + BroadcastNostrEventReceipt( + nostrEventId = nostrEvent.id, + isAccepted = true, + isSync = true, + relayURL = relayURL + ) + ) + + database.chatMessageBroadcastNostrEventReceiptRelationDao() + .upsert( + ChatMessageBroadcastNostrEventReceiptRelation( + broadcastNostrEventReceiptId = broadcastNostrEventReceiptId, + chatMessageId = chatMessageId + ) + ) + } else { + logger.w("Failed to setup chatRoom for message") } } } } } - } nostrEvent.toPost()?.let { post -> diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/repository/DatabaseNostrRepository.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/repository/DatabaseNostrRepository.kt index 4654b2e7..c295a284 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/repository/DatabaseNostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/repository/DatabaseNostrRepository.kt @@ -27,6 +27,7 @@ import ac.cord.auxiliary.compose.repository.RelayRepository import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags @@ -56,7 +57,6 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.tags.RelayTag import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import fr.acinq.phoenix.managers.WalletManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -390,13 +390,13 @@ class DatabaseNostrRepository( unsignedNostrEvent: UnsignedNostrEvent, nostrEvent: NostrEvent, relayURLs: List, - walletManager: WalletManager + activeKeyPair: KeyPair ) { database.nostrDao().publishNostrEvent( unsignedNostrEvent, nostrEvent, relayURLs, - walletManager = walletManager + activeKeyPair = activeKeyPair ) } @@ -462,7 +462,7 @@ class DatabaseNostrRepository( nostrEvent: NostrEvent, synchronizeNostrEventRequest: SynchronizeNostrEventRequest, synchronizationRelayURLs: List, - walletManager: WalletManager, + activeKeyPair: KeyPair, ) { storeNostrEventMutex.withLock { logger.d("saveNostrEvent: $nostrEvent") @@ -474,7 +474,7 @@ class DatabaseNostrRepository( synchronizationRelayURLs = synchronizationRelayURLs, relayURL = synchronizeNostrEventRequest.relayURL, level = synchronizeNostrEventRequest.level, - walletManager = walletManager + activeKeyPair = activeKeyPair ) database.synchronizeNostrEventRequestDao().upsert( @@ -494,7 +494,7 @@ class DatabaseNostrRepository( nostrEvent: NostrEvent, negentropySynchronizeRequest: NegentropySynchronizeRequest, synchronizationRelayURLs: List, - walletManager: WalletManager + activeKeyPair: KeyPair ) { logger.d("saveNostrEvent: $nostrEvent") @@ -504,7 +504,7 @@ class DatabaseNostrRepository( relayURL = negentropySynchronizeRequest.relayURL, synchronizationRelayURLs = synchronizationRelayURLs, level = negentropySynchronizeRequest.level, - walletManager = walletManager + activeKeyPair = activeKeyPair ) database.negentropySynchronizeRequestDao().upsert( diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/network/relays/RelaysSocketManager.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/network/relays/RelaysSocketManager.kt index abc49260..df28b5eb 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/network/relays/RelaysSocketManager.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/network/relays/RelaysSocketManager.kt @@ -22,10 +22,12 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd +import fr.acinq.phoenix.data.ActiveWallet import fr.acinq.phoenix.managers.WalletManager import fr.acinq.phoenix.managers.nostrPrivateKey import fr.acinq.phoenix.managers.nostrPublicKey import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest @@ -35,10 +37,10 @@ import kotlinx.coroutines.flow.collectLatest * https://github.com/PrimalHQ/primal-android-app/blob/6db2e6862239335dede4162338d7c4f10ad35031/app/src/main/kotlin/net/primal/android/networking/relays/RelaysSocketManager.kt */ class RelaysSocketManager constructor( + private val activeWalletStateFlow: StateFlow, private val nostrSocketClientFactory: NostrSocketClientFactory, private val cachingImportRepository: CachingImportRepository, private val relayRepository: RelayRepository, - private val walletManager: WalletManager ) { val logger = Logger.withTag("RelaysSocketManager") private val scope = CoroutineScope(Dispatchers.IO) @@ -67,13 +69,19 @@ class RelaysSocketManager constructor( private fun observeActiveUserId() = scope.launch { - walletManager.keyManager.collectLatest { keyManager -> - keyManager?.nostrPublicKey()?.value?.toHex()?.let { pubkey -> - observeRelayJobs[pubkey]?.cancel() - observeRelayJobs[pubkey] = observeRelays(pubkey) + activeWalletStateFlow.collectLatest { activeWallet -> + if (activeWallet == null) { + // TODO: Cancel all pending jobs? + } + activeWallet?.business?.walletManager?.keyManager?.collectLatest { keyManager -> + keyManager?.nostrPublicKey()?.let { pubkey -> + observeRelayJobs[pubkey]?.cancel() + observeRelayJobs[pubkey] = observeRelays(pubkey) + } } } + } private fun observeRelays(publicKey: String): Job = diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/repository/NostrRepository.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/repository/NostrRepository.kt index b5db7456..66f38436 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/repository/NostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/repository/NostrRepository.kt @@ -19,7 +19,7 @@ import ac.cord.auxiliary.compose.database.model.typealiases.SynchronizationFilte import ac.cord.auxiliary.compose.database.model.types.SynchronizationFilter import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind -import fr.acinq.phoenix.managers.WalletManager +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import kotlinx.coroutines.flow.Flow interface NostrRepository { @@ -67,7 +67,7 @@ interface NostrRepository { unsignedNostrEvent: UnsignedNostrEvent, nostrEvent: NostrEvent, relayURLs: List = emptyList(), - walletManager: WalletManager + activeKeyPair: KeyPair ) suspend fun broadcastProcessed(broadcastNostrEventRequest: BroadcastNostrEventRequest, status: String = "processing") @@ -85,14 +85,14 @@ interface NostrRepository { nostrEvent: NostrEvent, synchronizeNostrEventRequest: SynchronizeNostrEventRequest, synchronizationRelayURLs: List, - walletManager: WalletManager + activeKeyPair: KeyPair ) suspend fun saveNostrEvent( nostrEvent: NostrEvent, negentropySynchronizeRequest: NegentropySynchronizeRequest, synchronizationRelayURLs: List, - walletManager: WalletManager + activeKeyPair: KeyPair ) suspend fun queueSynchronizeNostrEvent( @@ -206,7 +206,7 @@ interface NostrRepository { unsignedNostrEvent: UnsignedNostrEvent, nostrEvent: NostrEvent, relayURLs: List, - walletManager: WalletManager + activeKeyPair: KeyPair ) { } @@ -234,7 +234,7 @@ interface NostrRepository { nostrEvent: NostrEvent, synchronizeNostrEventRequest: SynchronizeNostrEventRequest, synchronizationRelayURLs: List, - walletManager: WalletManager + activeKeyPair: KeyPair ) { TODO("Not yet implemented") } @@ -243,7 +243,7 @@ interface NostrRepository { nostrEvent: NostrEvent, negentropySynchronizeRequest: NegentropySynchronizeRequest, synchronizationRelayURLs: List, - walletManager: WalletManager + activeKeyPair: KeyPair ) { TODO("Not yet implemented") } diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/CreateProfileScreen.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/CreateProfileScreen.kt index 176b33ac..aa424252 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/CreateProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/CreateProfileScreen.kt @@ -45,17 +45,14 @@ import fr.acinq.phoenix.managers.WalletManager @Composable fun CreateProfileScreen( initialCreateProfileUIState: CreateProfileUIState = CreateProfileUIState.Declaration, - onNavigateToSocialPreconditionRoute: () -> Unit, onNavigateToEndThis: () -> Unit, nostrRepository: NostrRepository, - writeSeed: (List) -> Unit, - walletManager: WalletManager + writeSeed: (List) -> Unit ) { val createProfileViewModel: CreateProfileViewModel = viewModel ( factory = CreateProfileViewModel.factory( initialCreateProfileUIState, - nostrRepository, - walletManager = walletManager + nostrRepository ) ) Scaffold { innerPadding -> @@ -319,8 +316,7 @@ fun CreateProfileScreen( contentColor = Color.White ), onClick = { - - onNavigateToSocialPreconditionRoute.invoke() + createProfileViewModel.createAccount(writeSeed) } ) { Text( @@ -366,7 +362,6 @@ fun CreateAccountScreenPreview() { modifier = Modifier.fillMaxSize() ) { CreateProfileScreen( - walletManager = WalletManager(Chain.Mainnet), initialCreateProfileUIState = // CreateProfileUIState.InputPrompt, // CreateProfileUIState.Error, @@ -403,7 +398,6 @@ fun CreateAccountScreenPreview() { about = "Polyglot: Math... is my middle name, computer scientist. cryptographer, and gold hider." ) ), - onNavigateToSocialPreconditionRoute = {}, onNavigateToEndThis = {}, writeSeed = {}, nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/LoadingScreen.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/LoadingScreen.kt index dfdc99d8..19f47aa4 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/LoadingScreen.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/LoadingScreen.kt @@ -7,18 +7,22 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview @Composable fun LoadingScreen( + text: String? = null ) { Scaffold { innerPadding -> Column( modifier = Modifier.padding(innerPadding) ) { - LoadingDataIndicator() + LoadingDataIndicator( + text = text + ) } } } @@ -31,6 +35,8 @@ private fun LoadingScreenPreview() { modifier = Modifier.fillMaxSize() ) { LoadingScreen( + text = "Starting up" + ) } } diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/SovereignWalletStartupScreen.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/SovereignWalletStartupScreen.kt new file mode 100644 index 00000000..e2a4fdf9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/SovereignWalletStartupScreen.kt @@ -0,0 +1,363 @@ +package ac.cord.auxiliary.compose.ui.composable + +import ac.cord.auxiliary.compose.ui.composable.widgets.LoadingDataIndicator +import ac.cord.auxiliary.compose.ui.composable.widgets.wallet.WalletsSelector +import ac.cord.auxiliary.compose.ui.view.model.SovereignWalletStartupViewModel +import ac.cord.auxiliary.compose.ui.view.model.SovereignWalletViewModel +import ac.cord.auxiliary.compose.ui.view.model.StartupViewState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import fr.acinq.phoenix.data.ListWalletState +import fr.acinq.phoenix.data.UserWallet +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.managers.DataStoreManager +import fr.acinq.phoenix.managers.NodeParamsManager +import fr.acinq.phoenix.utils.preferences.UserPrefs +import fr.acinq.phoenix.utils.preferences.UserWalletMetadata +import fr.acinq.phoenix.utils.preferences.getByWalletIdOrDefault +import kotlinx.coroutines.flow.first +import kotlin.collections.get + +@Composable +fun SovereignWalletStartupScreen( + sovereignWalletViewModel: SovereignWalletViewModel, + onNavigateToWalletIntroPage: () -> Unit, + onNavigateToWalletLandingPage: () -> Unit, + onNavigateToWalletDashboard: () -> Unit, + forceWalletId: WalletId?, +) { + val sovereignWalletStartupViewModel = viewModel( + factory = SovereignWalletStartupViewModel.factory( + phoenixGlobal = sovereignWalletViewModel.phoenixGlobal, + ) + ) + + val showIntro = sovereignWalletStartupViewModel.getShowIntroFlow().collectAsState(initial = null) + if (showIntro.value == true) { + LaunchedEffect(Unit) { onNavigateToWalletIntroPage.invoke() } + } + + Box( + modifier = Modifier + .fillMaxSize() + .imePadding(), + contentAlignment = Alignment.Center + ) { + when (sovereignWalletViewModel.listWalletState.value) { + is ListWalletState.Init -> { + LoadingDataIndicator( + text = "Decrypting..." + ) + } + is ListWalletState.Error -> { + ImplementationPendingScreen( + "Failed to load wallet data", + ) + } + is ListWalletState.Success -> { + val globalPrefs = sovereignWalletViewModel.getGlobalPrefs() + + val availableWallets by sovereignWalletViewModel.availableWallets.collectAsState() + val defaultWallet = globalPrefs.getDefaultWallet.collectAsState(null) + val startWalletImmediately by sovereignWalletViewModel.startWalletImmediately.collectAsState() + + val availableWalletMetadataPrefs = globalPrefs.getAvailableWalletsMeta.collectAsState(null) + val availableWalletMetadata = availableWalletMetadataPrefs.value + + val desiredWalletIdFlow = sovereignWalletViewModel.desiredWalletId.collectAsState() + val desiredWalletId = desiredWalletIdFlow.value + + val activeWalletFlow = sovereignWalletViewModel.activeWalletInUI.collectAsState() + val activeWallet = activeWalletFlow.value + + when { + availableWallets.isEmpty() -> { + LaunchedEffect(Unit) { onNavigateToWalletLandingPage.invoke() } + LoadingDataIndicator(text = "Initializing...") + } + availableWalletMetadata == null || defaultWallet.value == null -> { + LoadingDataIndicator(text = "Preparing wallet...") + } + activeWallet != null -> { + LoadingDataIndicator(text = "Opening Wallet") + LaunchedEffect(Unit) { + sovereignWalletViewModel.loadSovereignData(activeWallet.id) + onNavigateToWalletDashboard.invoke() + } + } + else -> { + when (val startupState = sovereignWalletStartupViewModel.state.value) { + is StartupViewState.Init -> { + var loadingWallet by remember { + mutableStateOf( + when { + forceWalletId != null -> availableWallets[forceWalletId] + !startWalletImmediately -> null + availableWallets.size == 1 -> availableWallets.entries.firstOrNull()?.value + desiredWalletId != null -> availableWallets[desiredWalletId] + startWalletImmediately -> availableWallets[defaultWallet.value] + else -> null + } + ) + } + when (val wallet = loadingWallet) { + null -> { + WalletsSelector( + wallets = availableWallets, + globalPrefs = globalPrefs, + walletsMetadata = availableWalletMetadata, + activeWalletId = null, + onWalletClick = { sovereignWalletViewModel.switchToWallet(it.walletId) ; loadingWallet = it }, + canEdit = false, + modifier = Modifier.padding(horizontal = 24.dp), + topContent = { + Spacer(Modifier.height(64.dp)) + Text(text = "Select a wallet", style = MaterialTheme.typography.headlineSmall) + Spacer(Modifier.height(16.dp)) + }, + bottomContent = { + Spacer(Modifier.height(128.dp)) + } + ) + } + else -> { + val metadata = remember { availableWalletMetadata.getByWalletIdOrDefault(wallet.walletId) } + val dataStoreManager = DataStoreManager( + ctx = sovereignWalletViewModel.phoenixGlobal.ctx, + chain = NodeParamsManager.chain + ) + LoadWallet( + userWallet = wallet, + metadata = metadata, + userPrefs = dataStoreManager.loadUserPrefsForWallet(wallet.walletId), + promptScreenLockImmediately = startWalletImmediately, + doLoadWallet = { userWallet -> + sovereignWalletStartupViewModel.startupNode(walletId = userWallet.walletId, words = userWallet.words, onStartupSuccess = { + sovereignWalletViewModel.setActiveWallet(walletId = userWallet.walletId, business = it) + onNavigateToWalletDashboard.invoke() + }) + loadingWallet = null + }, + // only show back-to-selector button if there's more than one wallet + goToWalletSelector = availableWallets.takeIf { it.size > 1 }?.let { + { loadingWallet = null; sovereignWalletViewModel.startWalletImmediately.value = false } + } + ) + } + } + } + is StartupViewState.StartingBusiness -> { + LoadingDataIndicator(text = "Starting wallet") + } + is StartupViewState.BusinessActive -> { + LoadingDataIndicator(text = "Opening wallet") + } + is StartupViewState.Error -> { + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "Startup Error" + ) + + when (startupState) { + is StartupViewState.Error.Generic -> { + startupState.cause?.message?.let { message -> + Text( + text = message, + ) + } + } + } + Spacer(Modifier.height(16.dp)) + HorizontalDivider( + modifier = Modifier.width(50.dp) + ) + Spacer(Modifier.height(24.dp)) +// BorderButton( +// text = stringResource(R.string.startup_error_try_again), +// icon = R.drawable.ic_reset, +// onClick = onTryAgainClick +// ) + Spacer(Modifier.height(8.dp)) +// StartErrorShareLogsButton() + } + } + + } + } + } + } + } + } +} + +@Composable +private fun BoxScope.LoadWallet( + userWallet: UserWallet, + metadata: UserWalletMetadata, + userPrefs: UserPrefs, + promptScreenLockImmediately: Boolean, + doLoadWallet: (UserWallet) -> Unit, + goToWalletSelector: (() -> Unit)? +) { + + val isScreenLockRequired = produceState(initialValue = null, key1 = userWallet) { + val biometricLockEnabled = userPrefs.getLockBiometricsEnabled.first() + val customPinLockEnabled = userPrefs.getLockPinEnabled.first() + + value = biometricLockEnabled || customPinLockEnabled + } + + when (isScreenLockRequired.value) { + null -> { + LoadingDataIndicator(text = "Loading preferences...") + } + true -> { + LoadingDataIndicator(text = "Unlock to continue") + ScreenLockPrompt( + walletId = userWallet.walletId, + walletName = metadata.nameOrDefault(), + promptScreenLockImmediately = promptScreenLockImmediately, + onUnlock = { doLoadWallet(userWallet) }, + onLock = { }, + userPrefs = userPrefs, + goToWalletSelector = goToWalletSelector, + ) + } + false -> { + LoadingDataIndicator(text = "Starting wallet...") + LaunchedEffect(Unit) { + doLoadWallet(userWallet) + } + } + } +} + + +@Composable +private fun BoxScope.ScreenLockPrompt( + walletId: WalletId, + walletName: String, + userPrefs: UserPrefs, + promptScreenLockImmediately: Boolean, + onLock: () -> Unit, + onUnlock: () -> Unit, + goToWalletSelector: (() -> Unit)?, +) { + Text( + text = "Lock prompt coming soon.", + ) +// val scope = rememberCoroutineScope() +// +// +// val isBiometricLockEnabledState = userPrefs.getLockBiometricsEnabled.collectAsState(initial = null) +// val isBiometricLockEnabled = isBiometricLockEnabledState.value +// val isCustomPinLockEnabledState = userPrefs.getLockPinEnabled.collectAsState(initial = null) +// val isCustomPinLockEnabled = isCustomPinLockEnabledState.value +// +// val promptBiometricLock = { +// val promptInfo = BiometricPrompt.PromptInfo.Builder().apply { +// setTitle(context.getString(R.string.lockprompt_title)) +// setAllowedAuthenticators(BiometricsHelper.authCreds) +// }.build() +// BiometricsHelper.getPrompt( +// activity = context.findActivity(), +// onSuccess = { +// scope.launch { userPrefs.saveLockPinCodeSuccess() } +// onUnlock() +// }, +// onFailure = { onLock() }, +// onCancel = { } +// ).authenticate(promptInfo) +// } +// +// var showPinLockDialog by rememberSaveable { mutableStateOf(false) } +// if (showPinLockDialog) { +// CheckScreenLockPinFlow( +// walletId = walletId, +// onCancel = { showPinLockDialog = false }, +// onPinValid = { onUnlock() } +// ) +// } +// +// if (goToWalletSelector != null) { +//// TODO: BackHandler(enabled = !showPinLockDialog) { goToWalletSelector() } +// TransparentFilledButton( +//// icon = R.drawable.ic_arrow_back, +// onClick = goToWalletSelector, +// modifier = Modifier.align(Alignment.TopStart), +// padding = PaddingValues(24.dp), +// ) +// } +// +// Column( +// modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp), +// horizontalAlignment = Alignment.CenterHorizontally, +// verticalArrangement = Arrangement.spacedBy(16.dp) +// ) { +// if (isBiometricLockEnabled == true) { +// Button( +// text = "System lock", +//// icon = R.drawable.ic_fingerprint, +// onClick = promptBiometricLock, +// modifier = Modifier.fillMaxWidth(), +// backgroundColor = MaterialTheme.colors.surface, +// shape = CircleShape, +// padding = PaddingValues(16.dp), +// ) +// } +// if (isCustomPinLockEnabled == true) { +// Button( +// text = "PIN Code", +//// icon = R.drawable.ic_pin, +// onClick = { showPinLockDialog = true }, +// modifier = Modifier.fillMaxWidth(), +// backgroundColor = MaterialTheme.colors.surface, +// shape = CircleShape, +// padding = PaddingValues(16.dp), +// ) +// } +// Spacer(modifier = Modifier.height(WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding())) +// } +// +// when { +// isBiometricLockEnabled == true && isCustomPinLockEnabled == false -> { +// LaunchedEffect(Unit) { +// promptBiometricLock() +// } +// } +// +// isBiometricLockEnabled == false && isCustomPinLockEnabled == true -> { +// LaunchedEffect(Unit) { +// showPinLockDialog = true +// } +// } +// +// promptScreenLockImmediately -> { +// LaunchedEffect(key1 = isBiometricLockEnabled, key2 = isCustomPinLockEnabled, ) { +// if (isBiometricLockEnabled == true) { +// promptBiometricLock() +// } else if (isCustomPinLockEnabled == true) { +// showPinLockDialog = true +// } +// } +// } +// else -> {} +// } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/navigation/AuxNavHost.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/navigation/AuxNavHost.kt index e9304649..81cbcec8 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/navigation/AuxNavHost.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/navigation/AuxNavHost.kt @@ -16,6 +16,7 @@ import ac.cord.auxiliary.compose.ui.composable.SearchResultScreen import ac.cord.auxiliary.compose.ui.composable.SearchScreen import ac.cord.auxiliary.compose.ui.composable.SignInToProfileScreen import ac.cord.auxiliary.compose.ui.composable.SocialPreconditionScreen +import ac.cord.auxiliary.compose.ui.composable.SovereignWalletStartupScreen import ac.cord.auxiliary.compose.ui.composable.UnannouncedProfileScreen import ac.cord.auxiliary.compose.ui.composable.UnindexedProfileScreen import ac.cord.auxiliary.compose.ui.composable.UnqueuedProfileScreen @@ -40,6 +41,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.toRoute import ac.cord.auxiliary.compose.ui.composable.navigation.routes.SocialPreconditionRoute +import ac.cord.auxiliary.compose.ui.composable.navigation.routes.SovereignWalletStartupRoute import ac.cord.auxiliary.compose.ui.composable.navigation.routes.UnannouncedProfileRoute import ac.cord.auxiliary.compose.ui.composable.navigation.routes.UnindexedProfileRoute import ac.cord.auxiliary.compose.ui.composable.navigation.routes.UnqueuedProfileRoute @@ -49,6 +51,7 @@ import ac.cord.auxiliary.compose.ui.composable.navigation.routes.UnsyncedProfile import ac.cord.auxiliary.compose.ui.composable.navigation.routes.WriteNewNoteRoute import ac.cord.auxiliary.compose.ui.view.model.NavigationViewModel import ac.cord.auxiliary.compose.ui.view.model.NotaryViewModel +import ac.cord.auxiliary.compose.ui.view.model.SovereignWalletViewModel import ac.cord.auxiliary.compose.ui.view.model.SynchronizationViewModel import ac.cord.auxiliary.compose.ui.view.state.NavigationUIState import ac.cord.auxiliary.compose.ui.view.state.NostrEventDetailUIState @@ -56,6 +59,7 @@ import ac.cord.auxiliary.compose.ui.view.state.SearchUIState import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.lifecycle.compose.LocalLifecycleOwner @@ -76,7 +80,7 @@ fun AuxNavHost( navController: NavHostController ) { val logger = Logger.withTag("AuxNavHost") - +// TODO val exceptionHandler = CoroutineExceptionHandler { _, throwable -> logger.e("Caught exception: ${throwable.message}", throwable) @@ -102,30 +106,38 @@ fun AuxNavHost( database = auxDatabaseManager.auxDatabase ) + val sovereignWalletViewModel: SovereignWalletViewModel = viewModel(factory = SovereignWalletViewModel.factory( + phoenixGlobal = phoenixGlobal + )) + val navigationViewModel: NavigationViewModel = viewModel ( factory = NavigationViewModel.factory( - initialNavigationUIState = NavigationUIState.Loading, + activeWallet = sovereignWalletViewModel.activeWalletInUI, + initialNavigationUIState = NavigationUIState.Loading(), nostrRepository = databaseNostrRepository, - phoenixGlobal = phoenixGlobal, scope = applicationIOScope ) ) -// val notaryViewModel: NotaryViewModel = viewModel( -// factory = NotaryViewModel.factory( -// nostrRepository = databaseNostrRepository, -// chatRepository = databaseChatRepository, -// scope = applicationIOScope -// ) -// ) -// // TODO: Produce a notary UI Element... -// val synchronizationViewModel: SynchronizationViewModel = viewModel( -// factory = SynchronizationViewModel.factory( -// nostrRepository = databaseNostrRepository, -// chatRepository = databaseChatRepository, -// relayRepository = databaseNostrRepository, -// scope = applicationIOScope -// ) -// ) + + + val notaryViewModel: NotaryViewModel = viewModel( + factory = NotaryViewModel.factory( + activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + nostrRepository = databaseNostrRepository, + chatRepository = databaseChatRepository, + scope = applicationIOScope + ) + ) + // TODO: Produce a notary UI Element... + val synchronizationViewModel: SynchronizationViewModel = viewModel( + factory = SynchronizationViewModel.factory( + activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + nostrRepository = databaseNostrRepository, + chatRepository = databaseChatRepository, + relayRepository = databaseNostrRepository, + scope = applicationIOScope + ) + ) // TODO: Produce a synchronization UI element... LaunchedEffect(lifecycleOwner) { @@ -138,9 +150,18 @@ fun AuxNavHost( popUpTo(0) } } + is NavigationUIState.StartupPhoenix -> { + navController.navigate( + route = SovereignWalletStartupRoute + ) { + popUpTo(0) + } + } is NavigationUIState.Loading -> { navController.navigate( - route = LoadingRoute + route = LoadingRoute( + text = state.text + ) ) { popUpTo(0) } @@ -207,7 +228,9 @@ fun AuxNavHost( } is NavigationUIState.ProfileLoaded -> { navController.navigate( - route = SocialPreconditionRoute + route = SocialPreconditionRoute( + activeUserPubkey = state.publicKey + ) ) { popUpTo(0) } @@ -225,10 +248,37 @@ fun AuxNavHost( NavHost( navController = navController, - startDestination = LoadingRoute + startDestination = LoadingRoute() ) { - composable { - LoadingScreen() + composable { + SovereignWalletStartupScreen( + sovereignWalletViewModel = sovereignWalletViewModel, + onNavigateToWalletLandingPage = { + navController.navigate( + route = LandingRoute + ) + }, + onNavigateToWalletIntroPage = { + navController.navigate( + route = LandingRoute + ) + }, + onNavigateToWalletDashboard = { + navController.navigate( + route = LoadingRoute( + text = "Opening Aux" + ) + ) + }, + forceWalletId = null, + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + LoadingScreen( + text = route.text + ) } composable { LandingScreen( @@ -250,37 +300,32 @@ fun AuxNavHost( ) } composable { - val walletManager = navigationViewModel.activeWalletInUI.value?.business?.walletManager - - if (walletManager != null) { - CreateProfileScreen( - onNavigateToSocialPreconditionRoute = { - navController.navigate( - route = SocialPreconditionRoute - ) - }, - onNavigateToEndThis = { - navController.navigate( - route = BlankRoute - ) { - popUpTo(0) - } - }, - nostrRepository = databaseNostrRepository, - walletManager = walletManager, - writeSeed = { words -> - navigationViewModel.writeSeed( - words, - isRestoringWallet = false, - onSeedWritten = { walletId -> - - } - ) + CreateProfileScreen( + onNavigateToEndThis = { + navController.navigate( + route = BlankRoute + ) { + popUpTo(0) } - ) - } else { - ImplementationPendingScreen("Something went wrong") - } + }, + nostrRepository = databaseNostrRepository, + writeSeed = { words -> + sovereignWalletViewModel.writeSeed( + words, + isRestoringWallet = false, + onSeedWritten = { walletId -> + + sovereignWalletViewModel.loadSovereignData(walletId) + sovereignWalletViewModel.listAvailableWallets { + sovereignWalletViewModel.switchToWallet(walletId) + navController.navigate( + route = SovereignWalletStartupRoute + ) + } + } + ) + } + ) } composable { backStackEntry -> @@ -340,11 +385,14 @@ fun AuxNavHost( UnannouncedProfileScreen() } - composable { + composable { backStackEntry -> + val route = backStackEntry.toRoute() SocialPreconditionScreen( onNavigateToSkipForNow = { navController.navigate( - route = FeedRoute + route = FeedRoute( + activeUserPublicKey = route.activeUserPubkey + ) ) }, onNavigateToInviteFriend = { diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/navigation/routes/AuthenticatedProfileRoute.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/navigation/routes/AuthenticatedProfileRoute.kt new file mode 100644 index 00000000..c9217b7f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/navigation/routes/AuthenticatedProfileRoute.kt @@ -0,0 +1,7 @@ +package ac.cord.auxiliary.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +@Serializable +data object AuthenticatedProfileRoute { +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/navigation/routes/LoadingRoute.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/navigation/routes/LoadingRoute.kt index 7c892f08..a5e82bea 100755 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/navigation/routes/LoadingRoute.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/navigation/routes/LoadingRoute.kt @@ -3,4 +3,6 @@ package ac.cord.auxiliary.compose.ui.composable.navigation.routes import kotlinx.serialization.Serializable @Serializable -object LoadingRoute: Route() \ No newline at end of file +data class LoadingRoute( + val text: String? = null +): Route() \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/navigation/routes/SocialPreconditionRoute.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/navigation/routes/SocialPreconditionRoute.kt index 1530c194..a8c18f57 100755 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/navigation/routes/SocialPreconditionRoute.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/navigation/routes/SocialPreconditionRoute.kt @@ -3,4 +3,6 @@ package ac.cord.auxiliary.compose.ui.composable.navigation.routes import kotlinx.serialization.Serializable @Serializable -object SocialPreconditionRoute: Route() \ No newline at end of file +data class SocialPreconditionRoute( + val activeUserPubkey: String +): Route() \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/navigation/routes/SovereignWalletStartupRoute.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/navigation/routes/SovereignWalletStartupRoute.kt new file mode 100644 index 00000000..85a7acf7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/navigation/routes/SovereignWalletStartupRoute.kt @@ -0,0 +1,6 @@ +package ac.cord.auxiliary.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +@Serializable +data object SovereignWalletStartupRoute \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/LoadingDataIndicator.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/LoadingDataIndicator.kt index 6addfe46..de2980e3 100755 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/LoadingDataIndicator.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/LoadingDataIndicator.kt @@ -5,10 +5,12 @@ import androidx.compose.foundation.layout.* import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -17,6 +19,7 @@ fun LoadingDataIndicator( modifier: Modifier = Modifier.fillMaxWidth(), color: Color = MaterialTheme.colorScheme.secondary, fillScreen: Boolean = true, + text: String? = null ) { Column( modifier = modifier, @@ -32,6 +35,18 @@ fun LoadingDataIndicator( trackColor = MaterialTheme.colorScheme.surfaceVariant, ) + text?.let { + + Spacer( + modifier = Modifier.height(30.dp) + ) + + Text( + text = text, + modifier = Modifier.fillMaxWidth().padding(10.dp), + textAlign = TextAlign.Center + ) + } if (fillScreen) { Spacer(modifier = Modifier.weight(3f)) } @@ -45,7 +60,9 @@ fun LoadingDataIndicator( private fun TransferHistoryScreenPreview() { AuxTheme { Surface(modifier = Modifier.fillMaxSize()) { - LoadingDataIndicator() + LoadingDataIndicator( + text = "Starting up" + ) } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/wallet/WalletsSelector.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/wallet/WalletsSelector.kt new file mode 100644 index 00000000..0451f1c8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/wallet/WalletsSelector.kt @@ -0,0 +1,165 @@ +/* + * Copyright 2025 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ac.cord.auxiliary.compose.ui.composable.widgets.wallet + +import ac.cord.auxiliary.compose.ui.composable.widgets.LoadingDataIndicator +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.machankura.compose.ui.composable.widgets.buttons.Clickable +import fr.acinq.phoenix.data.UserWallet +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.utils.preferences.GlobalPrefs +import fr.acinq.phoenix.utils.preferences.UserWalletMetadata +import fr.acinq.phoenix.utils.preferences.getByWalletIdOrDefault + +@Composable +fun WalletsSelector( + modifier: Modifier = Modifier, + globalPrefs: GlobalPrefs, + wallets: Map, + walletsMetadata: Map, + activeWalletId: WalletId?, + canEdit: Boolean, + onWalletClick: (UserWallet) -> Unit, + verticalArrangement: Arrangement.Vertical = Arrangement.Top, + horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, + topContent: @Composable (() -> Unit)? = null, + bottomContent: @Composable (() -> Unit)? = null, +) { + + val currentWallet = remember(wallets) { wallets.entries.firstOrNull { it.key == activeWalletId }?.value } + val otherWalletsList = remember(wallets, walletsMetadata) { wallets.entries.filterNot { it.key == activeWalletId || walletsMetadata[it.key]?.isHidden == true }.toList() } + + LazyColumn(modifier = modifier, verticalArrangement = verticalArrangement, horizontalAlignment = horizontalAlignment) { + topContent?.let { + item { it.invoke() } + } + if (currentWallet != null) { + item { + AvailableWalletView( + userWallet = currentWallet, + globalPrefs = globalPrefs, + metadata = walletsMetadata.getByWalletIdOrDefault(currentWallet.walletId), + isCurrent = true, + canEdit = canEdit, + onClick = { onWalletClick(currentWallet) } + ) + Spacer(Modifier.height(12.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + } + } + items(items = otherWalletsList) { (walletId, userWallet) -> + AvailableWalletView( + userWallet = userWallet, + globalPrefs = globalPrefs, + metadata = walletsMetadata.getByWalletIdOrDefault(walletId), + isCurrent = false, + canEdit = canEdit, + onClick = { onWalletClick(userWallet) } + ) + Spacer(Modifier.height(8.dp)) + } + bottomContent?.let { + item { it.invoke() } + } + } +} + +@Composable +private fun AvailableWalletView( + modifier: Modifier = Modifier, + globalPrefs: GlobalPrefs, + userWallet: UserWallet, + metadata: UserWalletMetadata, + isCurrent: Boolean, + canEdit: Boolean, + onClick: (WalletId) -> Unit, +) { + var showWalletEditDialog by remember { mutableStateOf(false) } + if (showWalletEditDialog) { + LoadingDataIndicator( + text = "Edit function coming soon" + ) +// EditWalletDialog( +// onDismiss = { showWalletEditDialog = false }, +// walletId = userWallet.walletId, +// globalPrefs = globalPrefs, +// metadata = metadata, +// ) + } + + Clickable( + modifier = modifier, + onClick = { + if (isCurrent) { + if (canEdit) showWalletEditDialog = true else return@Clickable + } else { + onClick(userWallet.walletId) + } + }, + shape = RoundedCornerShape(16.dp), + backgroundColor = MaterialTheme.colorScheme.surface, + ) { + Row(modifier = Modifier.height(IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically) { + Row( + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 12.dp) + .weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + WalletAvatar(avatar = metadata.avatar, backgroundColor = Color.Transparent, internalPadding = PaddingValues(4.dp)) + Spacer(Modifier.width(12.dp)) + Column { + Text(text = metadata.nameOrDefault(), modifier = Modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.height(2.dp)) + Text(text = userWallet.nodeId, modifier = Modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.displayMedium.copy(fontFamily = FontFamily.Monospace, fontSize = 12.sp)) + } + } + if (isCurrent && canEdit) { + Spacer(Modifier.width(8.dp)) +// TODO: PhoenixIcon(R.drawable.ic_edit, tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(16.dp)) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/CreateProfileViewModel.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/CreateProfileViewModel.kt index 8e37d4e1..a7414d1d 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/CreateProfileViewModel.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/CreateProfileViewModel.kt @@ -31,22 +31,19 @@ import kotlinx.coroutines.launch class CreateProfileViewModel( val initialCreateProfileUIState: CreateProfileUIState, val createProfileFormState: CreateProfileFormState = CreateProfileFormState(), - val nostrRepository: NostrRepository, - val walletManager: WalletManager + val nostrRepository: NostrRepository ): ViewModel() { companion object { private const val TAG = "CreateAccountViewModel" fun factory( initialCreateProfileUIState: CreateProfileUIState, - nostrRepository: NostrRepository, - walletManager: WalletManager + nostrRepository: NostrRepository ): ViewModelProvider.Factory = viewModelFactory { initializer { CreateProfileViewModel( initialCreateProfileUIState, - nostrRepository = nostrRepository, - walletManager = walletManager + nostrRepository = nostrRepository ) } } @@ -106,8 +103,10 @@ class CreateProfileViewModel( remoteSwapInExtendedPublicKey = NodeParamsManager.remoteSwapInXpub ) + val pubkey = localKeyManager.nostrPublicKey() + logger.d("NostrKey: $pubkey") nostrRepository.createNewProfile( - localKeyManager.nostrPublicKey().toHex(), + pubkey, name = createProfileFormState.nameField.textFieldState.text.toString(), biography = createProfileFormState.biographyField.textFieldState.text.toString() ) diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/NavigationViewModel.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/NavigationViewModel.kt index f0de7f2d..e26ccacb 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/NavigationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/NavigationViewModel.kt @@ -1,94 +1,29 @@ package ac.cord.auxiliary.compose.ui.view.model -import ac.cord.auxiliary.compose.extensions.getGlobalPrefs import ac.cord.auxiliary.compose.repository.NostrRepository -import ac.cord.auxiliary.compose.ui.composable.widgets.wallet.WalletAvatars import ac.cord.auxiliary.compose.ui.view.state.NavigationUIState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import co.touchlab.kermit.Logger -import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair -import fr.acinq.lightning.logging.error -import fr.acinq.phoenix.PhoenixBusiness -import fr.acinq.phoenix.PhoenixGlobal import fr.acinq.phoenix.data.ActiveWallet -import fr.acinq.phoenix.data.DecryptSeedResult -import fr.acinq.phoenix.data.ElectrumConfig -import fr.acinq.phoenix.data.ListWalletState -import fr.acinq.phoenix.data.UserWallet -import fr.acinq.phoenix.data.WalletId -import fr.acinq.phoenix.managers.DataStoreManager -import fr.acinq.phoenix.managers.WalletManager -import fr.acinq.phoenix.managers.nostrPrivateKey import fr.acinq.phoenix.managers.nostrPublicKey -import fr.acinq.phoenix.utils.preferences.GlobalPrefs -import fr.acinq.phoenix.utils.preferences.UserWalletMetadata -import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.launch -sealed class WritingSeedState { - data object Init : WritingSeedState() - data class Writing(val mnemonics: List) : WritingSeedState() - data class WrittenToDisk(val walletId: WalletId) : WritingSeedState() - sealed class Error : WritingSeedState() { - data class Generic(val cause: Throwable) : Error() - data object CannotLoadSeedMap: Error() - data object SeedAlreadyExists: Error() - } -} - -expect fun updateBusinessActiveInUI(walletId: WalletId) -expect fun loadAndDecryptSeed(phoenixGlobal: PhoenixGlobal): DecryptSeedResult - -expect fun getAvailableWalletsMeta(phoenixGlobal: PhoenixGlobal): Flow> - -expect suspend fun saveAvailableWalletMeta(phoenixGlobal: PhoenixGlobal, metadata: UserWalletMetadata) - -expect suspend fun saveAvailableWalletMeta( - phoenixGlobal: PhoenixGlobal, - walletId: WalletId, - name: String?, - avatar: String, - isHidden: Boolean -) - -expect fun platformWriteSeed( - log: Logger, - phoenixGlobal: PhoenixGlobal, - globalPrefs: GlobalPrefs, - writingState: WritingSeedState, - viewModelScope: CoroutineScope, - mnemonics: List, - onWritingSeedError: (WritingSeedState.Error) -> Unit, - onWritingSeedStateWriting: (WritingSeedState.Writing) -> Unit, - isRestoringWallet: Boolean, - isTorEnabled: Boolean, - customElectrumServer: ElectrumConfig.Custom?, - onSeedWritten: (WalletId) -> Unit -) - - class NavigationViewModel( + val activeWalletStateFlow: StateFlow, initialNavigationUIState: NavigationUIState, - val phoenixGlobal: PhoenixGlobal, val nostrRepository: NostrRepository, val scope: CoroutineScope, ): ViewModel() { @@ -97,14 +32,14 @@ class NavigationViewModel( private const val TAG = "NavigationViewModel" fun factory( + activeWallet: StateFlow, initialNavigationUIState: NavigationUIState, nostrRepository: NostrRepository, - phoenixGlobal: PhoenixGlobal, scope: CoroutineScope, ): ViewModelProvider.Factory = viewModelFactory { initializer { NavigationViewModel( - phoenixGlobal = phoenixGlobal, + activeWallet, initialNavigationUIState = initialNavigationUIState, nostrRepository = nostrRepository, scope = scope @@ -120,18 +55,6 @@ class NavigationViewModel( ) val navigationUIState = _navigationUIState.asStateFlow() - val listWalletState = mutableStateOf(ListWalletState.Init) - - private val _availableWallets = MutableStateFlow>(emptyMap()) - val availableWallets = _availableWallets.asStateFlow() - - private val _desiredWalletId = MutableStateFlow(null) - val desiredWalletId = _desiredWalletId.asStateFlow() - val startWalletImmediately = MutableStateFlow(true) - - private val _activeWalletInUI = MutableStateFlow(null) - val activeWalletInUI = _activeWalletInUI.asStateFlow() - init { observeProfile() } @@ -141,241 +64,93 @@ class NavigationViewModel( scope.launch(Dispatchers.IO) { delay(2_100) // Looking busy... - logger.i("Navigation UI State is Landing") - - activeWalletInUI.collectLatest { activeWallet -> - if (activeWallet == null) { - // TODO: No active wallet + activeWalletStateFlow.collectLatest { activeWallet -> + logger.d("Active Wallet: $activeWallet") + if (activeWallet == null) { + _navigationUIState.getAndUpdate { + NavigationUIState.StartupPhoenix + } } else { - if (activeWallet.business != null) { - activeWallet.business.walletManager.keyManager.collectLatest { keyManager -> - keyManager?.nostrPublicKey()?.let { nostrPublicKey -> - val publicKey = nostrPublicKey.toHex() + if (activeWallet.business == null) { + _navigationUIState.getAndUpdate { + NavigationUIState.StartupPhoenix + } + } else { + val activeUserPublicKey = activeWallet.business.walletManager.keyManager.value?.nostrPublicKey() - logger.i("Observing: $publicKey") + if (activeUserPublicKey == null) { + _navigationUIState.getAndUpdate { + NavigationUIState.Loading(text = "Couldn't get the nostrKey") + } + } else { - nostrRepository.observeProfile( - publicKey = publicKey - ).distinctUntilChanged().collect { localProfile -> - logger.i("Local Profile: $localProfile") - _navigationUIState.getAndUpdate { - if (localProfile == null) { - logger.i("We need to be on the landing screen so user creates account") - NavigationUIState.Landing - } else if (localProfile.unsignedNostrEvent == null) { - logger.i("We need to be on the landing screen so user creates account") - NavigationUIState.Landing - } else if (localProfile.broadcastNostrEventReceipt != null) { - logger.i("We have a broadcast receipt: ${localProfile.profile}") - NavigationUIState.ProfileLoaded( - publicKey = localProfile.unsignedNostrEvent.pubKey - ) - } else if (localProfile.unsignedNostrEvent.signedAt != null && localProfile.profile != null) { - logger.i("We have successfully synced a profile: ${localProfile.profile}") - NavigationUIState.ProfileLoaded( - publicKey = localProfile.unsignedNostrEvent.pubKey - ) - } else if (localProfile.broadcastNostrEventRequest != null) { - logger.i("We have a broadcast request: ${localProfile.broadcastNostrEventRequest}") - NavigationUIState.UnannouncedProfile( - broadcastNostrEventRequest = localProfile.broadcastNostrEventRequest - ) - } else if (localProfile.profile != null) { - logger.i("We have a profile that needs to queued for broadcast: ${localProfile.profile}") - NavigationUIState.UnqueuedProfile( - profile = localProfile.profile - ) - } else if (localProfile.nostrEvent != null) { - logger.i("Nostr event with the profile needs to be indexed: ${localProfile.nostrEvent}") - NavigationUIState.UnindexedProfile( - nostrEvent = localProfile.nostrEvent - ) - } else if (localProfile.unsignedNostrEvent.signedAt != null && localProfile.synchronizeNostrEventRequests.isEmpty()) { - logger.i("We should request to sync profile: ${localProfile.unsignedNostrEvent}") - NavigationUIState.UnqueuedProfileSynchronization( - unsignedNostrEvent = localProfile.unsignedNostrEvent - ) - } else if (localProfile.unsignedNostrEvent.signedAt != null && localProfile.synchronizeNostrEventRequests.isEmpty()) { - logger.i("We should be syncing the profile: ${localProfile.unsignedNostrEvent}") - NavigationUIState.UnsyncedProfile( - unsignedNostrEvent = localProfile.unsignedNostrEvent - ) - } else { - logger.i("We have an unsigned profile: ${localProfile.unsignedNostrEvent}") - NavigationUIState.UnsignedProfile( - unsignedNostrEvent = localProfile.unsignedNostrEvent - ) - } + logger.i("Observing: $activeUserPublicKey") + + nostrRepository.observeProfile( + publicKey = activeUserPublicKey + ).distinctUntilChanged().collect { localProfile -> + logger.i("Local Profile: $localProfile") + _navigationUIState.getAndUpdate { + if (localProfile == null) { + logger.i("We need to be on the landing screen so user creates account") + NavigationUIState.Landing + } else if (localProfile.unsignedNostrEvent == null) { + logger.i("We need to be on the landing screen so user creates account") + NavigationUIState.Landing + } else if (localProfile.broadcastNostrEventReceipt != null) { + logger.i("We have a broadcast receipt: ${localProfile.profile}") + NavigationUIState.ProfileLoaded( + publicKey = localProfile.unsignedNostrEvent.pubKey + ) + } else if (localProfile.unsignedNostrEvent.signedAt != null && localProfile.profile != null) { + logger.i("We have successfully synced a profile: ${localProfile.profile}") + NavigationUIState.ProfileLoaded( + publicKey = localProfile.unsignedNostrEvent.pubKey + ) + } else if (localProfile.broadcastNostrEventRequest != null) { + logger.i("We have a broadcast request: ${localProfile.broadcastNostrEventRequest}") + NavigationUIState.UnannouncedProfile( + broadcastNostrEventRequest = localProfile.broadcastNostrEventRequest + ) + } else if (localProfile.profile != null) { + logger.i("We have a profile that needs to queued for broadcast: ${localProfile.profile}") + NavigationUIState.UnqueuedProfile( + profile = localProfile.profile + ) + } else if (localProfile.nostrEvent != null) { + logger.i("Nostr event with the profile needs to be indexed: ${localProfile.nostrEvent}") + NavigationUIState.UnindexedProfile( + nostrEvent = localProfile.nostrEvent + ) + } else if (localProfile.unsignedNostrEvent.signedAt != null && localProfile.synchronizeNostrEventRequests.isEmpty()) { + logger.i("We should request to sync profile: ${localProfile.unsignedNostrEvent}") + NavigationUIState.UnqueuedProfileSynchronization( + unsignedNostrEvent = localProfile.unsignedNostrEvent + ) + } else if (localProfile.unsignedNostrEvent.signedAt != null && localProfile.synchronizeNostrEventRequests.isEmpty()) { + logger.i("We should be syncing the profile: ${localProfile.unsignedNostrEvent}") + NavigationUIState.UnsyncedProfile( + unsignedNostrEvent = localProfile.unsignedNostrEvent + ) + } else { + logger.i("We have an unsigned profile: ${localProfile.unsignedNostrEvent}") + NavigationUIState.UnsignedProfile( + unsignedNostrEvent = localProfile.unsignedNostrEvent + ) } } } } - } else { - // TODO: We have an error somewhere... - } - } - } - - - } - } - - - fun setActiveWallet(walletId: WalletId, business: PhoenixBusiness) { - val dataStoreManager = DataStoreManager(business) - - val userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId = walletId) - val internalPrefs = dataStoreManager.loadInternalPrefsForWallet( walletId = walletId) - _activeWalletInUI.value = ActiveWallet(id = walletId, business = business, userPrefs = userPrefs, internalPrefs = internalPrefs) - updateBusinessActiveInUI(walletId) -// scheduleAutoLock() - } - - fun listAvailableWallets(onDone: () -> Unit) { - viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e -> -// logger.error("error when initialising startup-view: ", e) - listWalletState.value = ListWalletState.Error.Generic(e) - }) { - - when (val result = - loadAndDecryptSeed(phoenixGlobal)) { - is DecryptSeedResult.Failure.SerializationError -> { - logger.error {"cannot deserialize seed file: "} - listWalletState.value = ListWalletState.Error.Serialization - } - is DecryptSeedResult.Failure.DecryptionError -> { - logger.e("cannot decrypt seed file: ", throwable = result.cause) - listWalletState.value = ListWalletState.Error.DecryptionError.GeneralException(result.cause) - } - is DecryptSeedResult.Failure.KeyStoreFailure -> { - logger.e("key store failure: ", throwable = result.cause) - listWalletState.value = ListWalletState.Error.DecryptionError.KeystoreFailure(result.cause) - } - is DecryptSeedResult.Failure.SeedFileUnreadable -> { - logger.e("aborting, unreadable seed file") - listWalletState.value = ListWalletState.Error.Generic(null) - } - is DecryptSeedResult.Failure.SeedInvalid -> { - logger.e("aborting, seed is invalid") - listWalletState.value = ListWalletState.Error.Generic(null) - } - - is DecryptSeedResult.Failure.SeedFileNotFound -> { - listWalletState.value = ListWalletState.Success - _availableWallets.value = emptyMap() - } - - is DecryptSeedResult.Success -> { - - val metadataMap = getAvailableWalletsMeta( - phoenixGlobal - ).first() - result.userWalletsMap.forEach { (walletId, _) -> - if (metadataMap[walletId] == null) { - saveAvailableWalletMeta( - phoenixGlobal = phoenixGlobal, - walletId = walletId, - name = null, - avatar = WalletAvatars.list.random(), - isHidden = false - ) - } - } - _availableWallets.value = result.userWalletsMap - listWalletState.value = ListWalletState.Success - viewModelScope.launch(Dispatchers.Main) { - onDone() } + logger.i("Navigation UI State is Landing") } } } } -// fun scheduleAutoLock() { -// // TODO: Run this through an expect... -// viewModelScope.launch { -// autoLockHandler.removeCallbacksAndMessages(null) -// val activeUserPrefs = activeWalletInUI.first()?.userPrefs ?: return@launch -// -// val biometricLockEnabled = activeUserPrefs.getLockBiometricsEnabled.first() -// val customPinLockEnabled = activeUserPrefs.getLockPinEnabled.first() -// val autoLockDelay = activeUserPrefs.getAutoLockDelay.first() -// -// if ((biometricLockEnabled || customPinLockEnabled) && autoLockDelay != Duration.INFINITE) { -// autoLockHandler.postDelayed(autoLockRunnable, autoLockDelay.inWholeMilliseconds) -// } -// } -// } - - /** Clears the active wallet and signals the startup screen to load the given [walletId]. */ - fun switchToWallet(walletId: WalletId) { - _desiredWalletId.value = walletId - _activeWalletInUI.value = null - } - - /** Clears the active wallet. It does not affect [desiredWalletId]. The UI may still auto-open a specific wallet, if [desiredWalletId] is not null. */ - fun clearActiveWallet() { - _activeWalletInUI.value = null - } - - /** Resets the active wallet and [desiredWalletId]. The UI will redirect to the startup screen with the wallets selector prompt. */ - fun resetToSelector() { - _desiredWalletId.value = null - _activeWalletInUI.value = null - startWalletImmediately.value = false - } - override fun onCleared() { super.onCleared() logger.i("AppViewModel cleared") } - fun getPhoenixGlobalPrefs(): GlobalPrefs { - return getGlobalPrefs( - phoenixGlobal - ) - } - - // wallet initialisation options -- to be saved to user prefs once the wallet has been created and we know its walletId - var isTorEnabled = mutableStateOf(false) - var customElectrumServer = mutableStateOf(null) - - /** Monitors the writing of a seed on disk ; used by the restore view and the create view thru [writeSeed]. */ - var writingState by mutableStateOf( - WritingSeedState.Init - ) - private set - - - /** - * Attempts to write a seed on disk and updates the view model state. If a seed already - * exists on disk, this method will put the [writingState] in error. - */ - fun writeSeed( - mnemonics: List, - isRestoringWallet: Boolean, - onSeedWritten: (WalletId) -> Unit - ) { - platformWriteSeed( - log = logger, - phoenixGlobal = phoenixGlobal, - globalPrefs = getPhoenixGlobalPrefs(), - writingState = writingState, - viewModelScope = viewModelScope, - mnemonics = mnemonics, - onWritingSeedError = { writingSeedStateError -> - writingState = writingSeedStateError - }, - onWritingSeedStateWriting = { writing -> - writingState = writing - }, - isRestoringWallet = isRestoringWallet, - isTorEnabled = isTorEnabled.value, - customElectrumServer = customElectrumServer.value, - onSeedWritten = { walletId -> - onSeedWritten(walletId) - } - ) - } - } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/NotaryViewModel.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/NotaryViewModel.kt index ec69d43a..c7439b76 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/NotaryViewModel.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/NotaryViewModel.kt @@ -14,20 +14,23 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import fr.acinq.phoenix.data.ActiveWallet import fr.acinq.phoenix.managers.WalletManager import fr.acinq.phoenix.managers.nostrPrivateKey import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import kotlin.time.Instant class NotaryViewModel( + val activeWalletStateFlow: StateFlow, val nostrRepository: NostrRepository, val chatRepository: ChatRepository, val scope: CoroutineScope, - val walletManager: WalletManager, ): ViewModel() { companion object { @@ -35,17 +38,17 @@ class NotaryViewModel( fun factory( + activeWalletStateFlow: StateFlow, nostrRepository: NostrRepository, chatRepository: ChatRepository, - walletManager: WalletManager, scope: CoroutineScope, ): ViewModelProvider.Factory = viewModelFactory { initializer { NotaryViewModel( + activeWalletStateFlow = activeWalletStateFlow, nostrRepository = nostrRepository, chatRepository = chatRepository, scope = scope, - walletManager = walletManager ) } } @@ -54,13 +57,19 @@ class NotaryViewModel( private val logger = Logger.withTag(TAG) init { - walletManager.keyManager.value?.nostrPrivateKey()?.let { nostrPrivateKey -> - val keyPair = KeyPair( - privKey = nostrPrivateKey.value.toByteArray() - ) + scope.launch { + activeWalletStateFlow.collectLatest { activeWallet -> + logger.d("activeWallet: $activeWallet") + activeWallet?.business?.walletManager?.keyManager?.value?.nostrPrivateKey()?.let { nostrPrivateKey -> - observeUnsignedNostrEvents(keyPair) - observeUnsealedGiftWrapPayloads(keyPair) + val keyPair = KeyPair( + privKey = nostrPrivateKey.value.toByteArray() + ) + + observeUnsignedNostrEvents(keyPair) + observeUnsealedGiftWrapPayloads(keyPair) + } + } } } @@ -72,7 +81,7 @@ class NotaryViewModel( keyPair ) scope.launch(Dispatchers.IO) { - logger.i { "observeUnsignedNostrEvents" } + logger.i { "observeUnsignedNostrEvents: ${keyPair.pubKey.toHexKey()}" } nostrRepository.observeUnsignedNostrEvents( publicKey = keyPair.pubKey.toHexKey() ).distinctUntilChanged().collect { unsignedNostrEventOrNull -> @@ -100,7 +109,7 @@ class NotaryViewModel( unsignedNostrEventId = unsignedNostrEvent.id ), relayURLs = Relays.eventPublishRelaySet.map { normalizedRelayUrl -> normalizedRelayUrl.url }, - walletManager = walletManager + activeKeyPair = keyPair ) } } @@ -116,7 +125,7 @@ class NotaryViewModel( keyPair ) scope.launch(Dispatchers.IO) { - logger.i { "observeUnsealedGiftWrapPayloads" } + logger.i { "observeUnsealedGiftWrapPayloads: ${keyPair.pubKey.toHexKey()}" } chatRepository.observeUnsealedGiftWrapPayloads( publicKey = keyPair.pubKey.toHexKey() ).distinctUntilChanged().collect { giftWrapPayloadOrNull -> diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/SovereignWalletStartupViewModel.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/SovereignWalletStartupViewModel.kt new file mode 100644 index 00000000..ff342034 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/SovereignWalletStartupViewModel.kt @@ -0,0 +1,92 @@ +package ac.cord.auxiliary.compose.ui.view.model + +import ac.cord.auxiliary.compose.extensions.getShowIntroFlow +import ac.cord.auxiliary.compose.extensions.platformStartupLogic +import ac.cord.auxiliary.compose.extensions.schedulePlatformLogic +import androidx.compose.runtime.mutableStateOf +import androidx.datastore.preferences.core.Preferences +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.data.StartBusinessResult +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.utils.preferences.GlobalPrefs +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + + +sealed class StartupViewState { + data object Init : StartupViewState() + data class StartingBusiness(val walletId: WalletId) : StartupViewState() + data class BusinessActive(val walletId: WalletId): StartupViewState() + + sealed class Error: StartupViewState() { + abstract val walletId: WalletId + data class Generic(override val walletId: WalletId, val cause: Throwable?): Error() + } +} + +class SovereignWalletStartupViewModel( + val phoenixGlobal: PhoenixGlobal +): ViewModel() { + private val log = Logger.withTag("SovereignWalletStartupViewModel") + + val state = mutableStateOf(StartupViewState.Init) + + fun startupNode(walletId: WalletId, words: List, onStartupSuccess: (PhoenixBusiness) -> Unit) { + if (state.value !is StartupViewState.Init) { + return + } + + viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e -> + log.e("error when initialising startup-view: ", throwable = e) + state.value = StartupViewState.Error.Generic(walletId = walletId, cause = e) + }) { + state.value = StartupViewState.StartingBusiness(walletId) + + val startResult: StartBusinessResult = platformStartupLogic( + words + ) + + schedulePlatformLogic( + phoenixGlobal = phoenixGlobal, + ) + when (startResult) { + is StartBusinessResult.Success -> { + state.value =StartupViewState.BusinessActive(walletId) + launch(Dispatchers.Main) { + onStartupSuccess(startResult.business) + } + } + is StartBusinessResult.Failure.Generic -> state.value = StartupViewState.Error.Generic(walletId = walletId, cause = startResult.cause) + is StartBusinessResult.Failure.LoadWalletError -> state.value = StartupViewState.Error.Generic(walletId = walletId, cause = null) + } + } + } + + fun getShowIntroFlow(): Flow { + return getShowIntroFlow(phoenixGlobal) + } + + companion object { + fun factory( + phoenixGlobal: PhoenixGlobal, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + SovereignWalletStartupViewModel( + phoenixGlobal = phoenixGlobal, + ) + } + } + } + + +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/SovereignWalletViewModel.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/SovereignWalletViewModel.kt new file mode 100644 index 00000000..ac167d87 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/SovereignWalletViewModel.kt @@ -0,0 +1,293 @@ +package ac.cord.auxiliary.compose.ui.view.model + +import ac.cord.auxiliary.compose.extensions.getGlobalPrefs +import ac.cord.auxiliary.compose.ui.composable.widgets.wallet.WalletAvatars +import ac.cord.auxiliary.compose.ui.view.state.SovereignWalletUIState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import fr.acinq.bitcoin.MnemonicCode +import fr.acinq.lightning.crypto.LocalKeyManager +import fr.acinq.lightning.logging.error +import fr.acinq.lightning.utils.toByteVector +import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.data.ActiveWallet +import fr.acinq.phoenix.data.DecryptSeedResult +import fr.acinq.phoenix.data.ElectrumConfig +import fr.acinq.phoenix.data.ListWalletState +import fr.acinq.phoenix.data.UserWallet +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.managers.DataStoreManager +import fr.acinq.phoenix.managers.NodeParamsManager +import fr.acinq.phoenix.utils.preferences.GlobalPrefs +import fr.acinq.phoenix.utils.preferences.UserWalletMetadata +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.IO +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlin.String +import kotlin.plus + +sealed class WritingSeedState { + data object Init : WritingSeedState() + data class Writing(val mnemonics: List) : WritingSeedState() + data class WrittenToDisk(val walletId: WalletId) : WritingSeedState() + sealed class Error : WritingSeedState() { + data class Generic(val cause: Throwable) : Error() + data object CannotLoadSeedMap: Error() + data object SeedAlreadyExists: Error() + } +} + +expect fun updateBusinessActiveInUI(walletId: WalletId) +expect fun loadAndDecryptSeed(phoenixGlobal: PhoenixGlobal): DecryptSeedResult + +expect fun getAvailableWalletsMeta(phoenixGlobal: PhoenixGlobal): Flow> + +expect suspend fun saveAvailableWalletMeta(phoenixGlobal: PhoenixGlobal, metadata: UserWalletMetadata) + +expect suspend fun saveAvailableWalletMeta( + phoenixGlobal: PhoenixGlobal, + walletId: WalletId, + name: String?, + avatar: String, + isHidden: Boolean +) + +class SovereignWalletViewModel( + val phoenixGlobal: PhoenixGlobal, + // We might end up only using the machankuraWalletRepository in the future where we send the walletId in each request. +): ViewModel() { + private val log = Logger.withTag("SovereignWalletViewModel") + val listWalletState = mutableStateOf(ListWalletState.Init) + + private val _availableWallets = MutableStateFlow>(emptyMap()) + val availableWallets = _availableWallets.asStateFlow() + + private val _desiredWalletId = MutableStateFlow(null) + val desiredWalletId = _desiredWalletId.asStateFlow() + val startWalletImmediately = MutableStateFlow(true) + + private val _activeWalletInUI = MutableStateFlow(null) + val activeWalletInUI = _activeWalletInUI.asStateFlow() + + @OptIn(ExperimentalCoroutinesApi::class) + val exchangeRates = phoenixGlobal.currencyManager.ratesFlow + .stateIn(viewModelScope, started = SharingStarted.Lazily, initialValue = emptyList()) + + var walletUIState: SovereignWalletUIState by mutableStateOf(SovereignWalletUIState.Loading) + private set + + fun retryLoad() { + walletUIState = SovereignWalletUIState.Loading + } + + + fun loadSovereignData(walletId: WalletId) { + walletUIState = SovereignWalletUIState.Success( + publicKey = walletId.nodeIdHash, + balanceInSatoshis = 0 // TODO: Get balance... + ) + } + + + init { + listAvailableWallets(onDone = {}) + } + + fun setActiveWallet(walletId: WalletId, business: PhoenixBusiness) { + val dataStoreManager = DataStoreManager(business) + + val userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId = walletId) + val internalPrefs = dataStoreManager.loadInternalPrefsForWallet( walletId = walletId) + _activeWalletInUI.value = ActiveWallet(id = walletId, business = business, userPrefs = userPrefs, internalPrefs = internalPrefs) + updateBusinessActiveInUI(walletId) +// scheduleAutoLock() + } + + fun listAvailableWallets(onDone: () -> Unit) { + viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e -> +// log.error("error when initialising startup-view: ", e) + listWalletState.value = ListWalletState.Error.Generic(e) + }) { + + when (val result = loadAndDecryptSeed(phoenixGlobal)) { + is DecryptSeedResult.Failure.SerializationError -> { + log.error {"cannot deserialize seed file: "} + listWalletState.value = ListWalletState.Error.Serialization + } + is DecryptSeedResult.Failure.DecryptionError -> { + log.e("cannot decrypt seed file: ", throwable = result.cause) + listWalletState.value = ListWalletState.Error.DecryptionError.GeneralException(result.cause) + } + is DecryptSeedResult.Failure.KeyStoreFailure -> { + log.e("key store failure: ", throwable = result.cause) + listWalletState.value = ListWalletState.Error.DecryptionError.KeystoreFailure(result.cause) + } + is DecryptSeedResult.Failure.SeedFileUnreadable -> { + log.e("aborting, unreadable seed file") + listWalletState.value = ListWalletState.Error.Generic(null) + } + is DecryptSeedResult.Failure.SeedInvalid -> { + log.e("aborting, seed is invalid") + listWalletState.value = ListWalletState.Error.Generic(null) + } + + is DecryptSeedResult.Failure.SeedFileNotFound -> { + listWalletState.value = ListWalletState.Success + _availableWallets.value = emptyMap() + } + + is DecryptSeedResult.Success -> { + + val metadataMap = getAvailableWalletsMeta(phoenixGlobal).first() + result.userWalletsMap.forEach { (walletId, _) -> + if (metadataMap[walletId] == null) { + saveAvailableWalletMeta( + phoenixGlobal = phoenixGlobal, + walletId = walletId, + name = null, + avatar = WalletAvatars.list.random(), + isHidden = false + ) + } + } + _availableWallets.value = result.userWalletsMap + listWalletState.value = ListWalletState.Success + viewModelScope.launch(Dispatchers.Main) { + onDone() + } + } + } + } + } + +// fun scheduleAutoLock() { +// // TODO: Run this through an expect... +// viewModelScope.launch { +// autoLockHandler.removeCallbacksAndMessages(null) +// val activeUserPrefs = activeWalletInUI.first()?.userPrefs ?: return@launch +// +// val biometricLockEnabled = activeUserPrefs.getLockBiometricsEnabled.first() +// val customPinLockEnabled = activeUserPrefs.getLockPinEnabled.first() +// val autoLockDelay = activeUserPrefs.getAutoLockDelay.first() +// +// if ((biometricLockEnabled || customPinLockEnabled) && autoLockDelay != Duration.INFINITE) { +// autoLockHandler.postDelayed(autoLockRunnable, autoLockDelay.inWholeMilliseconds) +// } +// } +// } + + /** Clears the active wallet and signals the startup screen to load the given [walletId]. */ + fun switchToWallet(walletId: WalletId) { + _desiredWalletId.value = walletId + _activeWalletInUI.value = null + } + + /** Clears the active wallet. It does not affect [desiredWalletId]. The UI may still auto-open a specific wallet, if [desiredWalletId] is not null. */ + fun clearActiveWallet() { + _activeWalletInUI.value = null + } + + /** Resets the active wallet and [desiredWalletId]. The UI will redirect to the startup screen with the wallets selector prompt. */ + fun resetToSelector() { + _desiredWalletId.value = null + _activeWalletInUI.value = null + startWalletImmediately.value = false + } + + override fun onCleared() { + super.onCleared() + log.i("AppViewModel cleared") + } + + fun getGlobalPrefs(): GlobalPrefs { + return getGlobalPrefs( + phoenixGlobal + ) + } + + // wallet initialisation options -- to be saved to user prefs once the wallet has been created and we know its walletId + var isTorEnabled = mutableStateOf(false) + var customElectrumServer = mutableStateOf(null) + + /** Monitors the writing of a seed on disk ; used by the restore view and the create view thru [writeSeed]. */ + var writingState by mutableStateOf(WritingSeedState.Init) + private set + + + /** + * Attempts to write a seed on disk and updates the view model state. If a seed already + * exists on disk, this method will put the [writingState] in error. + */ + fun writeSeed( + mnemonics: List, + isRestoringWallet: Boolean, + onSeedWritten: (WalletId) -> Unit + ) { + platformWriteSeed( + log = log, + phoenixGlobal = phoenixGlobal, + globalPrefs = getGlobalPrefs(), + writingState = writingState, + viewModelScope = viewModelScope, + mnemonics = mnemonics, + onWritingSeedError = { writingSeedStateError -> + writingState = writingSeedStateError + }, + onWritingSeedStateWriting = { writing -> + writingState = writing + }, + isRestoringWallet = isRestoringWallet, + isTorEnabled = isTorEnabled.value, + customElectrumServer = customElectrumServer.value, + onSeedWritten = { walletId -> + onSeedWritten(walletId) + } + ) + } + + companion object { + private const val TAG = "SovereignWalletViewModel" + fun factory( + phoenixGlobal: PhoenixGlobal, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + SovereignWalletViewModel( + phoenixGlobal = phoenixGlobal, + ) + } + } + } +} + +expect fun platformWriteSeed( + log: Logger, + phoenixGlobal: PhoenixGlobal, + globalPrefs: GlobalPrefs, + writingState: WritingSeedState, + viewModelScope: CoroutineScope, + mnemonics: List, + onWritingSeedError: (WritingSeedState.Error) -> Unit, + onWritingSeedStateWriting: (WritingSeedState.Writing) -> Unit, + isRestoringWallet: Boolean, + isTorEnabled: Boolean, + customElectrumServer: ElectrumConfig.Custom?, + onSeedWritten: (WalletId) -> Unit +) diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/SynchronizationViewModel.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/SynchronizationViewModel.kt index 36b6037a..581e243c 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/SynchronizationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/SynchronizationViewModel.kt @@ -12,6 +12,7 @@ import ac.cord.auxiliary.compose.repository.CachingImportRepository import ac.cord.auxiliary.compose.repository.ChatRepository import ac.cord.auxiliary.compose.repository.NostrRepository import ac.cord.auxiliary.compose.repository.RelayRepository +import androidx.compose.runtime.key import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.initializer @@ -19,17 +20,22 @@ import androidx.lifecycle.viewmodel.viewModelFactory import co.touchlab.kermit.Logger import com.vitorpamplona.negentropy.Negentropy import com.vitorpamplona.negentropy.storage.StorageVector +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd +import fr.acinq.phoenix.data.ActiveWallet import fr.acinq.phoenix.managers.WalletManager +import fr.acinq.phoenix.managers.nostrPrivateKey import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.timeout import kotlinx.coroutines.launch @@ -38,18 +44,18 @@ import kotlinx.coroutines.sync.withLock import kotlin.time.Duration.Companion.milliseconds class SynchronizationViewModel( + val activeWalletStateFlow: StateFlow, val nostrRepository: NostrRepository, val chatRepository: ChatRepository, val relayRepository: RelayRepository, - val walletManager: WalletManager, val scope: CoroutineScope, ): ViewModel() { val relaysSocketManager = RelaysSocketManager( + activeWalletStateFlow = activeWalletStateFlow, nostrSocketClientFactory = NostrSocketClientFactory, cachingImportRepository = CachingImportRepository.NO_OP_CACHING_IMPORT_REPOSITORY, relayRepository = relayRepository, - walletManager = walletManager ) companion object { @@ -58,18 +64,18 @@ class SynchronizationViewModel( private val mutex = Mutex() fun factory( + activeWalletStateFlow: StateFlow, nostrRepository: NostrRepository, chatRepository: ChatRepository, relayRepository: RelayRepository, - walletManager: WalletManager, scope: CoroutineScope, ): ViewModelProvider.Factory = viewModelFactory { initializer { SynchronizationViewModel( + activeWalletStateFlow = activeWalletStateFlow, nostrRepository = nostrRepository, chatRepository = chatRepository, relayRepository = relayRepository, - walletManager = walletManager, scope = scope ) } @@ -79,12 +85,25 @@ class SynchronizationViewModel( private val logger = Logger.withTag(TAG) init { - observePendingBroadcastNostrEventRequests() - observePendingSyncNostrEventRequests() - observePendingNegentropySynchronizeRequests() + scope.launch { + activeWalletStateFlow.collectLatest { activeWallet -> + activeWallet?.business?.walletManager?.keyManager?.value?.nostrPrivateKey()?.let { nostrPrivateKey -> + val keyPair = KeyPair( + privKey = nostrPrivateKey.value.toByteArray() + ) + + observePendingBroadcastNostrEventRequests(keyPair) + observePendingSyncNostrEventRequests(keyPair) + observePendingNegentropySynchronizeRequests(keyPair) + } + } + } + } - private fun observePendingSyncNostrEventRequests() { + private fun observePendingSyncNostrEventRequests( + keyPair: KeyPair + ) { logger.i { "observePendingSyncNostrEventRequests" } scope.launch(Dispatchers.IO) { @@ -125,7 +144,7 @@ class SynchronizationViewModel( nostrEvent = it, synchronizeNostrEventRequest, synchronizationRelayURLs = listOf(synchronizeNostrEventRequest.relayURL), - walletManager = walletManager + activeKeyPair = keyPair // TODO: + Relays.eventPublishRelaySet.map { normalizedRelayUrl -> normalizedRelayUrl.url } ) } @@ -139,7 +158,7 @@ class SynchronizationViewModel( nostrEvent = nostrEvent, synchronizeNostrEventRequest, synchronizationRelayURLs = listOf(synchronizeNostrEventRequest.relayURL), - walletManager = walletManager + activeKeyPair = keyPair // TODO: + Relays.eventPublishRelaySet.map { normalizedRelayUrl -> normalizedRelayUrl.url } ) } @@ -170,7 +189,9 @@ class SynchronizationViewModel( } } - private fun observePendingNegentropySynchronizeRequests() { + private fun observePendingNegentropySynchronizeRequests( + keyPair: KeyPair + ) { logger.i { "observePendingNegentropySynchronizeRequests" } scope.launch(Dispatchers.IO) { @@ -238,7 +259,7 @@ class SynchronizationViewModel( nostrEvent = it, negentropySynchronizeRequest, synchronizationRelayURLs = listOf(negentropySynchronizeRequest.relayURL), - walletManager = walletManager + activeKeyPair = keyPair // TODO: + Relays.eventPublishRelaySet.map { normalizedRelayUrl -> normalizedRelayUrl.url } ) } @@ -252,7 +273,7 @@ class SynchronizationViewModel( nostrEvent = nostrEvent, negentropySynchronizeRequest, synchronizationRelayURLs = listOf(negentropySynchronizeRequest.relayURL), - walletManager = walletManager + activeKeyPair = keyPair // TODO: + Relays.eventPublishRelaySet.map { normalizedRelayUrl -> normalizedRelayUrl.url } ) } @@ -356,7 +377,9 @@ class SynchronizationViewModel( } @OptIn(FlowPreview::class) - private fun observePendingBroadcastNostrEventRequests() { + private fun observePendingBroadcastNostrEventRequests( + keyPair: KeyPair + ) { logger.i { "observePendingBroadcastNostrEventRequests" } scope.launch(Dispatchers.IO) { diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/state/NavigationUIState.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/state/NavigationUIState.kt index a11de7fd..acb0ff6e 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/state/NavigationUIState.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/state/NavigationUIState.kt @@ -8,8 +8,11 @@ import ac.cord.auxiliary.compose.database.model.UnsignedNostrEvent abstract class NavigationUIState { data object Error: NavigationUIState() - data object Loading: NavigationUIState() + data class Loading( + val text: String? = null + ): NavigationUIState() + data object StartupPhoenix: NavigationUIState() data object Landing: NavigationUIState() data class UnsignedProfile( diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/state/SovereignWalletUIState.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/state/SovereignWalletUIState.kt new file mode 100644 index 00000000..c4f488e5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/state/SovereignWalletUIState.kt @@ -0,0 +1,17 @@ +package ac.cord.auxiliary.compose.ui.view.state + +import fr.acinq.phoenix.PhoenixBusiness + +sealed interface SovereignWalletUIState { + data object Uninitialized: SovereignWalletUIState + + data class Success( + val publicKey: String, + val balanceInSatoshis: Long, + ): SovereignWalletUIState + + + data object Error: SovereignWalletUIState + data object Loading: SovereignWalletUIState +} + diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/WalletManager.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/WalletManager.kt index 2949111b..12ad36e2 100644 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/WalletManager.kt +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/WalletManager.kt @@ -16,6 +16,9 @@ package fr.acinq.phoenix.managers +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import fr.acinq.bitcoin.* import fr.acinq.lightning.crypto.Bip84OnChainKeys import fr.acinq.lightning.crypto.LocalKeyManager @@ -99,8 +102,10 @@ fun LocalKeyManager.nostrPrivateKey(): PrivateKey { return derivePrivateKey(path).privateKey } -fun LocalKeyManager.nostrPublicKey(): PublicKey { - return nostrPrivateKey().publicKey() +fun LocalKeyManager.nostrPublicKey(): HexKey { + return KeyPair( + privKey = nostrPrivateKey().value.toByteArray() + ).pubKey.toHexKey() } fun LocalKeyManager.cloudKeyHash(): String {