diff --git a/.gitignore b/.gitignore index ae839c321..1700753e3 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ local.properties # AI .ai +.codex/ .claude/worktrees *.local.* !*.local.template* diff --git a/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt b/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt index c5717f394..ad4875b25 100644 --- a/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt +++ b/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt @@ -63,6 +63,7 @@ data class PrivatePaykitCacheData( val contacts: Map = emptyMap(), val cleanupPending: Boolean = false, val deletedContactCleanupPendingPublicKeys: Set = emptySet(), + val profileRecoveryPending: Boolean = false, ) @Serializable @@ -77,6 +78,7 @@ data class PrivatePaykitContactCacheData( val mainRecoveryAttemptId: String? = null, val responderRecoveryAttemptId: String? = null, val lastCompletedRecoveryAttemptId: String? = null, + val awaitingRecoveredRemoteEndpoints: Boolean = false, val linkFailureCount: Int = 0, ) diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index 03888d46b..1e0e6884c 100644 --- a/app/src/main/java/to/bitkit/data/SettingsStore.kt +++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt @@ -102,6 +102,9 @@ data class SettingsData( val hasSeenContactsIntro: Boolean = false, val hasConfirmedPublicPaykitEndpoints: Boolean = false, val sharesPublicPaykitEndpoints: Boolean = false, + val sharesPrivatePaykitEndpoints: Boolean = false, + val publicPaykitLightningEnabled: Boolean = true, + val publicPaykitOnchainEnabled: Boolean = true, val publicPaykitBolt11: String = "", val publicPaykitBolt11PaymentHash: String = "", val publicPaykitBolt11ExpiresAtMillis: Long = 0, diff --git a/app/src/main/java/to/bitkit/models/BackupPayloads.kt b/app/src/main/java/to/bitkit/models/BackupPayloads.kt index 27bea5d2f..26002660f 100644 --- a/app/src/main/java/to/bitkit/models/BackupPayloads.kt +++ b/app/src/main/java/to/bitkit/models/BackupPayloads.kt @@ -34,6 +34,7 @@ data class PrivatePaykitContactLinkBackupV1( val recoveryStartedAt: Long? = null, val mainRecoveryAttemptId: String? = null, val responderRecoveryAttemptId: String? = null, + val awaitingRecoveredRemoteEndpoints: Boolean = false, ) @Serializable diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index b13fba708..038c0f9c2 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -392,9 +392,39 @@ class ActivityRepo @Inject constructor( } } + suspend fun clearContact( + forPaymentId: String, + syncLdkPayments: Boolean = true, + ): Result = withContext(ioDispatcher) { + runCatching { + if (syncLdkPayments) { + lightningRepo.getPayments().onSuccess { + syncLdkNodePayments(it).getOrThrow() + }.getOrThrow() + } + + val activity = findActivityForPaymentId(forPaymentId, syncLdkPayments) + if (activity == null) { + Logger.warn( + "Skipped clearing contact for payment '$forPaymentId' because activity was not found", + context = TAG, + ) + return@runCatching + } + if (activity.contact() == null) return@runCatching + + val updatedAt = nowTimestamp().epochSecond.toULong() + val updatedActivity = activity.withContact(null, updatedAt) + updateActivity(updatedActivity.rawId(), updatedActivity).getOrThrow() + updateReplacementContactIfNeeded(updatedActivity, null, updatedAt) + }.onFailure { + Logger.error("Failed to clear contact for payment '$forPaymentId'", it, context = TAG) + } + } + private suspend fun updateReplacementContactIfNeeded( activity: Activity, - normalizedKey: String, + normalizedKey: String?, updatedAt: ULong, ) { if (activity !is Activity.Onchain || activity.v1.doesExist || activity.v1.txType != PaymentType.SENT) return @@ -422,7 +452,7 @@ class ActivityRepo @Inject constructor( coreService.activity.getActivity(forPaymentId) ?: getOnchainActivityByTxId(forPaymentId)?.let { Activity.Onchain(it) } - private fun Activity.withContact(normalizedKey: String, updatedAt: ULong): Activity = when (this) { + private fun Activity.withContact(normalizedKey: String?, updatedAt: ULong): Activity = when (this) { is Activity.Lightning -> Activity.Lightning(v1.copy(contact = normalizedKey, updatedAt = updatedAt)) is Activity.Onchain -> Activity.Onchain(v1.copy(contact = normalizedKey, updatedAt = updatedAt)) } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitErrorClassifier.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitErrorClassifier.kt index c3bd69d60..9dd68941e 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitErrorClassifier.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitErrorClassifier.kt @@ -52,7 +52,7 @@ internal object PrivatePaykitErrorClassifier { private fun isNoiseStateFailure(reason: String): Boolean { val lowercasedReason = reason.lowercase() - return listOf("decrypt", "decryption", "cipher", "noise state", "counter", "invalid tag", "bad mac") + return listOf("decrypt", "decryption", "cipher", "invalid tag", "bad mac") .any { it in lowercasedReason } } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt index 3205b721d..fc8bd22f8 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt @@ -10,6 +10,7 @@ import to.bitkit.utils.AppError sealed class PrivatePaykitError(message: String, cause: Throwable? = null) : AppError(message, cause) { data object PrivateUnavailable : PrivatePaykitError("Private Paykit is not available") data object PayloadTooLarge : PrivatePaykitError("Private Paykit payload is too large") + data object RouteHintsUnavailable : PrivatePaykitError("Reachable private Lightning endpoint is not available yet") data object SnapshotRecipientMismatch : PrivatePaykitError("Private Paykit snapshot recipient mismatch") data object StaleLinkState : PrivatePaykitError("Private Paykit link state changed") class StatePersistenceFailed(cause: Throwable) : PrivatePaykitError("Failed to persist private Paykit state", cause) @@ -43,12 +44,14 @@ internal data class PrivatePaykitState( fun cacheState( cleanupPending: Boolean, deletedContactCleanupPendingPublicKeys: Set, + profileRecoveryPending: Boolean, ) = PrivatePaykitCacheData( contacts = contacts.mapNotNull { (publicKey, contactState) -> (publicKey to contactState.cacheState()).takeIf { contactState.hasCacheState } }.toMap(), cleanupPending = cleanupPending, deletedContactCleanupPendingPublicKeys = deletedContactCleanupPendingPublicKeys, + profileRecoveryPending = profileRecoveryPending, ) } @@ -65,6 +68,7 @@ internal data class ContactState( var mainRecoveryAttemptId: String? = null, var responderRecoveryAttemptId: String? = null, var lastCompletedRecoveryAttemptId: String? = null, + var awaitingRecoveredRemoteEndpoints: Boolean = false, var linkFailureCount: Int = 0, ) { constructor(cache: PrivatePaykitContactCacheData) : this( @@ -78,6 +82,7 @@ internal data class ContactState( mainRecoveryAttemptId = cache.mainRecoveryAttemptId, responderRecoveryAttemptId = cache.responderRecoveryAttemptId, lastCompletedRecoveryAttemptId = cache.lastCompletedRecoveryAttemptId, + awaitingRecoveredRemoteEndpoints = cache.awaitingRecoveredRemoteEndpoints, linkFailureCount = cache.linkFailureCount, ) @@ -103,6 +108,7 @@ internal data class ContactState( mainRecoveryAttemptId != null || responderRecoveryAttemptId != null || lastCompletedRecoveryAttemptId != null || + awaitingRecoveredRemoteEndpoints || linkFailureCount != 0 fun cacheState() = PrivatePaykitContactCacheData( @@ -116,6 +122,7 @@ internal data class ContactState( mainRecoveryAttemptId = mainRecoveryAttemptId, responderRecoveryAttemptId = responderRecoveryAttemptId, lastCompletedRecoveryAttemptId = lastCompletedRecoveryAttemptId, + awaitingRecoveredRemoteEndpoints = awaitingRecoveredRemoteEndpoints, linkFailureCount = linkFailureCount, ) } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRecoveryStore.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRecoveryStore.kt index 0e7f5fe9b..74ff1bad9 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRecoveryStore.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRecoveryStore.kt @@ -97,6 +97,14 @@ internal class PrivatePaykitRecoveryStore( return true } + return purgePrivatePaymentStorage(reason) + } + + suspend fun purgePrivatePaymentOutboxForProfileRecovery(reason: String): Boolean = + purgePrivatePaymentStorage(reason) + + @Suppress("ReturnCount") + private suspend fun purgePrivatePaymentStorage(reason: String): Boolean { val sessionSecret = keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) ?: return false if (sessionSecret.isBlank()) return false val rootPath = PRIVATE_STORAGE_ROOT_PATH.removeSuffix("/") diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index aa9ab9659..e0f2eff05 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -42,6 +42,11 @@ import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime +private data class PrivatePaymentAttempt( + val result: Result, + val shouldDeferPublicFallback: Boolean, +) + @OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) @Singleton @Suppress("TooManyFunctions", "LongParameterList", "LargeClass") @@ -66,12 +71,13 @@ class PrivatePaykitRepo @Inject constructor( private const val RECOVERY_MARKER_STAGE_INIT = "init" private const val RECOVERY_MARKER_STAGE_RESPONSE = "response" private const val RECOVERY_MARKER_STAGE_FINAL = "final" - private const val COMPLETED_LINK_RECOVERY_MARKER_GRACE_SECONDS = 5 * 60L private const val FRESH_LINK_INITIAL_PUBLISH_DELAY_SECONDS = 8L private const val PENDING_PUBLICATION_RETRY_ATTEMPTS = 60 + private const val PRIVATE_PAYMENT_RECOVERY_RETRY_ATTEMPTS = 12 private val privateInvoiceExpiry = 24.hours private val invoiceRefreshBuffer = 30.minutes private val pendingPublicationRetryDelay = 5.seconds + private val privatePaymentRecoveryRetryDelay = 2.seconds fun shouldInitiate(ownPublicKey: String, remotePublicKey: String): Boolean { val own = PubkyPublicKeyFormat.normalized(ownPublicKey) ?: ownPublicKey @@ -100,20 +106,86 @@ class PrivatePaykitRepo @Inject constructor( suspend fun reconcileReservedReceiveIndexes(): Result = addressReservationRepo.reconcileReservedIndexesWithLdk() - suspend fun prepareSavedContacts(publicKeys: Collection): Result = withContext(serializedDispatcher) { + suspend fun prepareSavedContacts( + publicKeys: Collection, + requireImmediatePublication: Boolean = false, + ): Result = withContext(serializedDispatcher) { + runCatching { + val keys = rememberSavedContacts(publicKeys, replacing = true) + if (!canPublishPrivateEndpoints()) return@runCatching + if (isProfileRecoveryPending() && keys.isNotEmpty()) { + recoverSavedContactsAfterProfileRecreation( + publicKeys = keys, + requireImmediatePublication = requireImmediatePublication, + ).getOrThrow() + return@runCatching + } + addressReservationRepo.reconcileReservedIndexesWithLdk().getOrThrow() + publishLocalEndpoints( + publicKeys = keys, + maxAdvanceSteps = 3, + reason = "prepare", + requireImmediatePublication = requireImmediatePublication, + ).getOrThrow() + } + } + + private suspend fun recoverSavedContactsAfterProfileRecreation( + publicKeys: Collection, + requireImmediatePublication: Boolean, + ): Result = withContext(serializedDispatcher) { runCatching { val keys = rememberSavedContacts(publicKeys, replacing = true) + if (keys.isEmpty()) return@runCatching if (!canPublishPrivateEndpoints()) return@runCatching + + advanceStateGeneration() + resetInFlightWork() + val didPurgeStaleTransport = recoveryStore.purgePrivatePaymentOutboxForProfileRecovery("profile recovery") + if (!didPurgeStaleTransport) { + updateProfileRecoveryPending(true) + if (requireImmediatePublication) throw PrivatePaykitError.PrivateUnavailable + return@runCatching + } + markContactsForProfileRecovery(keys, clock.now().epochSeconds) + persistState(markWalletBackup = true) + updateProfileRecoveryPending(false) + addressReservationRepo.reconcileReservedIndexesWithLdk().getOrThrow() - publishLocalEndpoints(keys, maxAdvanceSteps = 3, reason = "prepare").getOrThrow() + publishLocalEndpoints( + publicKeys = keys, + maxAdvanceSteps = 3, + reason = "profile recovery", + forceLocalPublishWhenRemoteEmpty = true, + requireImmediatePublication = requireImmediatePublication, + ).getOrThrow() } } + suspend fun enableSharingAndPrepareSavedContacts(publicKeys: Collection): Result = + withContext(serializedDispatcher) { + runCatching { + val wasCleanupPending = isContactSharingCleanupPending() + if (wasCleanupPending && !canPublishPrivateEndpoints()) return@runCatching + updateContactSharingCleanupPending(false) + prepareSavedContacts(publicKeys).onFailure { + if (wasCleanupPending) { + runCatching { updateContactSharingCleanupPending(true) } + .onFailure(it::addSuppressed) + } + }.getOrThrow() + } + } + suspend fun refreshSavedContactEndpoints(publicKeys: Collection): Result = withContext(serializedDispatcher) { runCatching { val keys = rememberSavedContacts(publicKeys, replacing = true) if (!canPublishPrivateEndpoints()) return@runCatching + if (isProfileRecoveryPending() && keys.isNotEmpty()) { + recoverSavedContactsAfterProfileRecreation(keys, requireImmediatePublication = false).getOrThrow() + return@runCatching + } publishLocalEndpoints(keys, maxAdvanceSteps = 1, reason = "refresh").getOrThrow() } } @@ -124,6 +196,13 @@ class PrivatePaykitRepo @Inject constructor( ): Result = withContext(serializedDispatcher) { runCatching { if (!canPublishPrivateEndpoints()) return@runCatching + if (isProfileRecoveryPending() && knownSavedContactKeys.isNotEmpty()) { + recoverSavedContactsAfterProfileRecreation( + publicKeys = knownSavedContactKeys.toList(), + requireImmediatePublication = false, + ).getOrThrow() + return@runCatching + } publishLocalEndpoints( publicKeys = knownSavedContactKeys.toList(), maxAdvanceSteps = 1, @@ -140,14 +219,9 @@ class PrivatePaykitRepo @Inject constructor( ): Result = withContext(serializedDispatcher) { runCatching { if (isContactSharingCleanupPending()) { - if (settingsStore.data.first().sharesPublicPaykitEndpoints) { - updateContactSharingCleanupPending(false) - } else { - publicPaykitRepo.syncPublishedEndpoints(publish = false).getOrThrow() - removePublishedEndpoints().getOrThrow() - clearUnsavedContactState(savedPublicKeys).getOrThrow() - updateContactSharingCleanupPending(false) - } + removePublishedEndpoints().getOrThrow() + clearUnsavedContactState(savedPublicKeys).getOrThrow() + updateContactSharingCleanupPending(false) } retryPendingDeletedContactEndpointRemoval(savedPublicKeys).getOrThrow() }.onFailure { @@ -190,12 +264,18 @@ class PrivatePaykitRepo @Inject constructor( withContext(serializedDispatcher) { runCatching { resetInFlightWork() - removePublishedEndpoints().onFailure { + val removalError = removePublishedEndpoints().exceptionOrNull() + if (removalError != null) { updateContactSharingCleanupPending(true) - Logger.warn("Failed to remove private Paykit endpoints before clearing state", it, context = TAG) - }.getOrThrow() - clearUnsavedContactState(savedPublicKeys).getOrThrow() - updateContactSharingCleanupPending(false) + Logger.warn( + "Deferred private Paykit endpoint cleanup after disable failed", + removalError, + context = TAG, + ) + } else { + clearUnsavedContactState(savedPublicKeys).getOrThrow() + updateContactSharingCleanupPending(false) + } } } @@ -213,10 +293,15 @@ class PrivatePaykitRepo @Inject constructor( } } - suspend fun closeAndClear(): Result = withContext(serializedDispatcher) { + suspend fun closeAndClear( + markProfileRecoveryPending: Boolean = false, + ): Result = withContext(serializedDispatcher) { runCatching { publicationMutex.withLock { linkEstablishmentMutex.withLock { + val hadPrivateContactState = + ensureState().contacts.isNotEmpty() + val wasProfileRecoveryPending = isProfileRecoveryPending() resetInFlightWork() closeActiveHandles() activeHandlesByContact.clear() @@ -224,6 +309,9 @@ class PrivatePaykitRepo @Inject constructor( stateStore.replaceState(PrivatePaykitState()) keychain.delete(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name) cacheStore.reset() + if (markProfileRecoveryPending && (hadPrivateContactState || wasProfileRecoveryPending)) { + updateProfileRecoveryPending(true) + } addressReservationRepo.clearContactAssignments(excludingPublicKeys = emptySet()) notifyBackupStateChanged() } @@ -236,19 +324,40 @@ class PrivatePaykitRepo @Inject constructor( runCatching { val normalizedKey = knownSavedContact(publicKey) ?: return@runCatching publicPaykitRepo.beginPayment(publicKey).getOrThrow() + if (!hasLocalSecretKeyForCurrentProfile()) { + return@runCatching publicPaykitRepo.beginPayment(normalizedKey).getOrThrow() + } - val privateResult = runCatching { beginPrivatePayment(normalizedKey).getOrThrow() } + val privateAttempt = beginPrivatePaymentWithRecoveryRetry(normalizedKey) + val privateResult = privateAttempt.result .onFailure { if (it is CancellationException) throw it + } + .getOrNull() + + if (privateResult is PublicPaykitPaymentResult.Opened) return@runCatching privateResult + if ( + privateAttempt.shouldDeferPublicFallback || + shouldDeferPublicFallbackForPrivateRecovery(normalizedKey) + ) { + privateAttempt.result.exceptionOrNull()?.let { Logger.warn( - "Falling back to public Paykit for '${redacted(normalizedKey)}'", + "Deferring public Paykit fallback for '${redacted(normalizedKey)}' " + + "while private payment recovery completes", it, context = TAG, ) } - .getOrNull() - - if (privateResult is PublicPaykitPaymentResult.Opened) return@runCatching privateResult + clearAwaitingRecoveredRemoteEndpoints(normalizedKey) + return@runCatching privateResult ?: PublicPaykitPaymentResult.NoEndpoint + } + privateAttempt.result.exceptionOrNull()?.let { + Logger.warn( + "Falling back to public Paykit for '${redacted(normalizedKey)}'", + it, + context = TAG, + ) + } publicPaykitRepo.beginPayment(normalizedKey).getOrThrow() } } @@ -379,6 +488,7 @@ class PrivatePaykitRepo @Inject constructor( recoveryStartedAt = contactState.recoveryStartedAt, mainRecoveryAttemptId = contactState.mainRecoveryAttemptId, responderRecoveryAttemptId = contactState.responderRecoveryAttemptId, + awaitingRecoveredRemoteEndpoints = contactState.awaitingRecoveredRemoteEndpoints, ) }.toMap().takeIf { it.isNotEmpty() } } @@ -424,6 +534,7 @@ class PrivatePaykitRepo @Inject constructor( recoveryStartedAt = contactBackup.recoveryStartedAt, mainRecoveryAttemptId = contactBackup.mainRecoveryAttemptId, responderRecoveryAttemptId = contactBackup.responderRecoveryAttemptId, + awaitingRecoveredRemoteEndpoints = contactBackup.awaitingRecoveredRemoteEndpoints, ) }.toMap() @@ -491,6 +602,55 @@ class PrivatePaykitRepo @Inject constructor( } } + private suspend fun beginPrivatePaymentWithRecoveryRetry(publicKey: String): PrivatePaymentAttempt { + var shouldDeferPublicFallback = shouldDeferPublicFallbackForPrivateRecovery(publicKey) + var result = runCatching { beginPrivatePayment(publicKey).getOrThrow() } + repeat(PRIVATE_PAYMENT_RECOVERY_RETRY_ATTEMPTS) { + shouldDeferPublicFallback = shouldDeferPublicFallback || + shouldDeferPublicFallbackForPrivateRecovery(publicKey) + if (!shouldRetryPrivatePaymentBeforePublicFallback(publicKey, result, shouldDeferPublicFallback)) { + return PrivatePaymentAttempt(result, shouldDeferPublicFallback) + } + delay(privatePaymentRecoveryRetryDelay) + result = runCatching { beginPrivatePayment(publicKey).getOrThrow() } + } + shouldDeferPublicFallback = shouldDeferPublicFallback || + shouldDeferPublicFallbackForPrivateRecovery(publicKey) + return PrivatePaymentAttempt(result, shouldDeferPublicFallback) + } + + private suspend fun shouldRetryPrivatePaymentBeforePublicFallback( + publicKey: String, + result: Result, + shouldDeferPublicFallback: Boolean, + ): Boolean { + if (result.getOrNull() is PublicPaykitPaymentResult.Opened) return false + result.exceptionOrNull()?.let { + if (it is CancellationException) throw it + if (it is PrivatePaykitError.PrivateUnavailable) { + return shouldDeferPublicFallback || shouldDeferPublicFallbackForPrivateRecovery(publicKey) + } + } + return shouldDeferPublicFallback || shouldDeferPublicFallbackForPrivateRecovery(publicKey) + } + + private suspend fun shouldDeferPublicFallbackForPrivateRecovery(publicKey: String): Boolean { + val contactState = ensureState().contacts[publicKey] ?: return false + return contactState.recoveryStartedAt != null || + contactState.mainRecoveryAttemptId != null || + contactState.responderRecoveryAttemptId != null || + (contactState.handshakeSnapshotHex != null && contactState.linkCompletedAt == null) || + contactState.awaitingRecoveredRemoteEndpoints + } + + private suspend fun clearAwaitingRecoveredRemoteEndpoints(publicKey: String) { + val contactState = ensureState().contacts[publicKey] ?: return + if (!contactState.awaitingRecoveredRemoteEndpoints) return + + contactState.awaitingRecoveredRemoteEndpoints = false + persistState() + } + private suspend fun cachedPrivatePaymentResult(publicKey: String): PublicPaykitPaymentResult { val cachedEntries = ensureState().contacts[publicKey]?.remoteEndpoints.orEmpty() val endpoints = cachedEntries.mapNotNull { @@ -507,7 +667,7 @@ class PrivatePaykitRepo @Inject constructor( return PublicPaykitPaymentResult.Opened(PublicPaykitRepo.paymentRequest(payable)) } - @Suppress("CyclomaticComplexMethod") + @Suppress("CyclomaticComplexMethod", "LongMethod") private suspend fun publishLocalEndpoints( publicKeys: Collection, maxAdvanceSteps: Int, @@ -515,9 +675,11 @@ class PrivatePaykitRepo @Inject constructor( scheduleRetries: Boolean = true, forceLocalPublishWhenRemoteEmpty: Boolean = false, forceRefreshLightning: Boolean = false, + requireImmediatePublication: Boolean = false, ): Result = withContext(serializedDispatcher) { runCatching { val generation = currentStateGeneration() + var firstError: Throwable? = null publicKeys.forEach { publicKey -> val normalizedKey = knownSavedContact(publicKey) ?: return@forEach val redactedKey = redacted(normalizedKey) @@ -527,7 +689,14 @@ class PrivatePaykitRepo @Inject constructor( maxAdvanceSteps = maxAdvanceSteps, generation = generation, scheduleRetries = scheduleRetries, - ) ?: return@forEach + ) ?: run { + val shouldFailImmediatePublish = requireImmediatePublication && + shouldRequirePrivateEndpointRemoval(normalizedKey) + if (firstError == null && shouldFailImmediatePublish) { + firstError = PrivatePaykitError.PrivateUnavailable + } + return@forEach + } if (publishLocalEndpointsBeforeFetch(normalizedKey, linkId, reason, scheduleRetries, generation)) { return@forEach @@ -539,7 +708,14 @@ class PrivatePaykitRepo @Inject constructor( reason = reason, scheduleRetries = scheduleRetries, generation = generation, - ) ?: return@forEach + ) ?: run { + val shouldFailImmediatePublish = requireImmediatePublication && + shouldRequirePrivateEndpointRemoval(normalizedKey) + if (firstError == null && shouldFailImmediatePublish) { + firstError = PrivatePaykitError.PrivateUnavailable + } + return@forEach + } val contactState = ensureState().contacts[normalizedKey] val shouldForcePublish = forceLocalPublishWhenRemoteEmpty && fetchedCount == 0 && @@ -558,18 +734,22 @@ class PrivatePaykitRepo @Inject constructor( it, context = TAG, ) + if (firstError == null && requireImmediatePublication) firstError = it } val updatedContactState = ensureState().contacts[normalizedKey] val needsRetry = publishResult.isFailure || updatedContactState?.linkCompletedAt == null || updatedContactState.lastLocalPayloadHash == null || - (fetchedCount == 0 && updatedContactState.remoteEndpoints.isEmpty()) + (fetchedCount == 0 && updatedContactState.remoteEndpoints.isEmpty()) || + shouldRetryMissingPrivateLightningEndpoint(normalizedKey) if (scheduleRetries && needsRetry) { schedulePendingPublicationRetry(normalizedKey) } else { cancelPendingPublicationRetry(normalizedKey) } } + firstError?.let { throw it } + Unit } } @@ -713,7 +893,8 @@ class PrivatePaykitRepo @Inject constructor( val contactState = ensureState().contacts[publicKey] val needsRetry = contactState?.linkCompletedAt == null || contactState.lastLocalPayloadHash == null || - contactState.remoteEndpoints.isEmpty() + contactState.remoteEndpoints.isEmpty() || + shouldRetryMissingPrivateLightningEndpoint(publicKey) if (needsRetry) schedulePendingPublicationRetry(publicKey, remainingAttempts - 1) } } @@ -751,10 +932,10 @@ class PrivatePaykitRepo @Inject constructor( if (!canPublishPrivateEndpoints() || knownSavedContact(publicKey) == null) return@withLock val endpoints = buildLocalEndpoints(publicKey, forceRefreshLightning).getOrThrow() + if (endpoints.isEmpty()) throw PublicPaykitError.NoSupportedEndpoint ensureCurrentGeneration(generation) val payloadSelection = PrivatePaykitPayloads.entriesWithinNoiseLimit(endpoints) if (payloadSelection.droppedLightning) { - ensureState().contacts[publicKey]?.localInvoice = null Logger.warn( "Published private Paykit on-chain only for '${redacted(publicKey)}'", context = TAG, @@ -784,16 +965,19 @@ class PrivatePaykitRepo @Inject constructor( ): Result> = withContext(serializedDispatcher) { runCatching { + val settings = settingsStore.data.first() val endpoints = mutableListOf() - val reservedAddress = addressReservationRepo.currentOrRotatedAddress(publicKey).getOrThrow() - walletRepo.refreshReusableReceiveAddressIfReserved().getOrThrow() - endpoints += Endpoint( - methodId = PublicPaykitRepo.onchainMethodId(reservedAddress), - value = reservedAddress, - rawPayload = PublicPaykitRepo.serializePayload(reservedAddress), - ) + if (PublicPaykitRepo.isOnchainPaymentOptionEnabled(settings)) { + val reservedAddress = addressReservationRepo.currentOrRotatedAddress(publicKey).getOrThrow() + walletRepo.refreshReusableReceiveAddressIfReserved().getOrThrow() + endpoints += Endpoint( + methodId = PublicPaykitRepo.onchainMethodId(reservedAddress), + value = reservedAddress, + rawPayload = PublicPaykitRepo.serializePayload(reservedAddress), + ) + } - if (lightningRepo.canReceive()) { + if (PublicPaykitRepo.isLightningPaymentOptionEnabled(settings) && lightningRepo.canReceive()) { currentOrRotatedInvoice(publicKey, forceRefresh = forceRefreshLightning).onSuccess { invoice -> endpoints += Endpoint( methodId = MethodId.Bolt11, @@ -801,17 +985,15 @@ class PrivatePaykitRepo @Inject constructor( rawPayload = PublicPaykitRepo.serializePayload(invoice.bolt11), ) }.onFailure { - ensureState().contacts[publicKey]?.localInvoice = null - persistState() + if (it is PrivatePaykitError.RouteHintsUnavailable) { + schedulePendingPublicationRetry(publicKey) + } Logger.warn( "Failed to prepare private Paykit invoice for '${redacted(publicKey)}'", it, context = TAG, ) } - } else { - ensureState().contacts[publicKey]?.localInvoice = null - persistState() } endpoints @@ -835,6 +1017,9 @@ class PrivatePaykitRepo @Inject constructor( val decoded = (coreService.decode(bolt11) as? Scanner.Lightning)?.invoice ?: throw PublicPaykitError.InvalidPayload + if (!PublicPaykitRepo.hasLightningRouteHints(bolt11)) { + throw PrivatePaykitError.RouteHintsUnavailable + } val expiresAt = decoded.timestampSeconds.toLong() + decoded.expirySeconds.toLong() val invoice = StoredInvoice( bolt11 = bolt11, @@ -855,9 +1040,18 @@ class PrivatePaykitRepo @Inject constructor( if (isReceivedInvoiceSettled(invoice.paymentHash)) return null val decoded = (coreService.decode(invoice.bolt11) as? Scanner.Lightning)?.invoice ?: return null if (decoded.isExpired || decoded.amountSatoshis != 0uL) return null + if (!PublicPaykitRepo.hasLightningRouteHints(invoice.bolt11)) return null return invoice } + private suspend fun shouldRetryMissingPrivateLightningEndpoint(publicKey: String): Boolean { + val settings = settingsStore.data.first() + if (!PublicPaykitRepo.isLightningPaymentOptionEnabled(settings)) return false + if (!lightningRepo.canReceive()) return false + + return reusablePrivateInvoice(publicKey) == null + } + private suspend fun fetchRemoteEndpoints( publicKey: String, linkId: String, @@ -899,8 +1093,9 @@ class PrivatePaykitRepo @Inject constructor( ensureCurrentGeneration(generation) if (remoteEntries.isEmpty()) return@runCatching 0 - ensureState().contacts.getOrPut(publicKey) { ContactState() }.remoteEndpoints = - remoteEntries.map { StoredPaymentEntry(it.methodId, it.endpointData) } + val contactState = ensureState().contacts.getOrPut(publicKey) { ContactState() } + contactState.remoteEndpoints = remoteEntries.map { StoredPaymentEntry(it.methodId, it.endpointData) } + contactState.awaitingRecoveredRemoteEndpoints = false persistState(markWalletBackup = true) remoteEntries.count() } @@ -1105,6 +1300,7 @@ class PrivatePaykitRepo @Inject constructor( contactState.recoveryStartedAt = remoteRecoveryMarker.createdAt contactState.lastLocalPayloadHash = null contactState.remoteEndpoints = emptyList() + contactState.awaitingRecoveredRemoteEndpoints = false persistState(markWalletBackup = true) } recoveryStore.publishRecoveryMarker( @@ -1132,6 +1328,7 @@ class PrivatePaykitRepo @Inject constructor( contactState.recoveryStartedAt = createdAt contactState.lastLocalPayloadHash = null contactState.remoteEndpoints = emptyList() + contactState.awaitingRecoveredRemoteEndpoints = false persistState(markWalletBackup = true) recoveryStore.publishRecoveryMarker( from = ownPublicKey, @@ -1459,6 +1656,19 @@ class PrivatePaykitRepo @Inject constructor( persistState(markWalletBackup = true) } + private suspend fun markContactsForProfileRecovery(publicKeys: Collection, startedAt: Long) { + val state = ensureState() + publicKeys.forEach { publicKey -> + cancelPendingPublicationRetry(publicKey) + activeHandlesByContact[publicKey]?.linkId?.let { runCatching { pubkyService.closeEncryptedLink(it) } } + activeHandlesByContact[publicKey]?.handshakeId?.let { + runCatching { pubkyService.dropEncryptedLinkHandshake(it) } + } + activeHandlesByContact[publicKey] = ContactPaykitHandles() + state.contacts[publicKey] = ContactState(recoveryStartedAt = startedAt) + } + } + private suspend fun closeActiveHandles() { activeHandlesByContact.values.forEach { handles -> handles.linkId?.let { runCatching { pubkyService.closeEncryptedLink(it) } } @@ -1486,6 +1696,7 @@ class PrivatePaykitRepo @Inject constructor( contactState.responderRecoveryAttemptId = null if (completedAttemptId != null) { contactState.lastCompletedRecoveryAttemptId = completedAttemptId + contactState.awaitingRecoveredRemoteEndpoints = true } if (linkWasReplaced || contactState.linkCompletedAt == null) { contactState.linkCompletedAt = clock.now().epochSeconds @@ -1505,7 +1716,14 @@ class PrivatePaykitRepo @Inject constructor( when { endpoint.methodId == MethodId.Bolt11 -> { val paymentHash = paymentHashForBolt11(endpoint.value)?.lowercase() ?: return@filter false - if (paymentHash in attemptedHashes) { + if (!PublicPaykitRepo.hasLightningRouteHints(endpoint.value)) { + staleLightningHashes += paymentHash + Logger.warn( + "Ignoring private Paykit invoice without route hints for '${redacted(publicKey)}'", + context = TAG, + ) + false + } else if (paymentHash in attemptedHashes) { staleLightningHashes += paymentHash Logger.warn( "Ignoring already-attempted private Paykit invoice for '${redacted(publicKey)}'", @@ -1565,12 +1783,21 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun canPublishPrivateEndpoints(): Boolean { val settings = settingsStore.data.first() - return settings.sharesPublicPaykitEndpoints && + return settings.sharesPrivatePaykitEndpoints && + hasLocalSecretKeyForCurrentProfile() && App.currentActivity?.value != null && walletRepo.walletExists() && lightningRepo.lightningState.value.nodeLifecycleState.isRunning() } + private suspend fun hasLocalSecretKeyForCurrentProfile(): Boolean = runCatching { + val ownPublicKey = pubkyService.currentPublicKey() ?: return@runCatching false + val secretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) + ?: return@runCatching false + val derivedPublicKey = pubkyService.publicKeyFromSecret(secretKeyHex) + PubkyPublicKeyFormat.matches(derivedPublicKey, ownPublicKey) + }.getOrDefault(false) + private suspend fun isContactSharingCleanupPending(): Boolean = cacheStore.data.first().cleanupPending @@ -1578,6 +1805,13 @@ class PrivatePaykitRepo @Inject constructor( cacheStore.update { it.copy(cleanupPending = isPending) } } + private suspend fun isProfileRecoveryPending(): Boolean = + cacheStore.data.first().profileRecoveryPending + + private suspend fun updateProfileRecoveryPending(isPending: Boolean) { + cacheStore.update { it.copy(profileRecoveryPending = isPending) } + } + private suspend fun pendingDeletedContactCleanupPublicKeys(): Set = cacheStore.data.first().deletedContactCleanupPendingPublicKeys @@ -1662,6 +1896,7 @@ class PrivatePaykitRepo @Inject constructor( recoveryStartedAt = startedAt mainRecoveryAttemptId = null responderRecoveryAttemptId = null + awaitingRecoveredRemoteEndpoints = false } persistState(markWalletBackup = true) return true @@ -1685,7 +1920,7 @@ class PrivatePaykitRepo @Inject constructor( private fun shouldReplaceUsableLink(marker: RecoveryMarker, publicKey: String): Boolean { if (isCompletedRecoveryMarker(marker, publicKey)) return false val linkCompletedAt = stateStore.currentState()?.contacts?.get(publicKey)?.linkCompletedAt ?: return true - return marker.createdAt > linkCompletedAt + COMPLETED_LINK_RECOVERY_MARKER_GRACE_SECONDS + return marker.createdAt > linkCompletedAt } private suspend fun settledPrivateInvoicePaymentHashes(): List { @@ -1752,6 +1987,7 @@ class PrivatePaykitRepo @Inject constructor( contactState.recoveryStartedAt = clock.now().epochSeconds contactState.mainRecoveryAttemptId = null contactState.responderRecoveryAttemptId = null + contactState.awaitingRecoveredRemoteEndpoints = false persistState(markWalletBackup = true) } @@ -1772,6 +2008,7 @@ class PrivatePaykitRepo @Inject constructor( contactState.recoveryStartedAt = null contactState.mainRecoveryAttemptId = null contactState.responderRecoveryAttemptId = null + contactState.awaitingRecoveredRemoteEndpoints = false contactState.linkFailureCount = 0 } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitStateStore.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitStateStore.kt index 61553a64b..bc58102f9 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitStateStore.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitStateStore.kt @@ -66,6 +66,7 @@ internal class PrivatePaykitStateStore( } else { emptySet() }, + profileRecoveryPending = if (preserveCleanupMarkers) stored.profileRecoveryPending else false, ) } if (markWalletBackup) notifyBackupStateChanged() diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 1b666780b..5b51e5085 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.data.PubkyStore import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain @@ -74,6 +75,7 @@ class PubkyRepo @Inject constructor( private val imageLoader: ImageLoader, private val pubkyStore: PubkyStore, private val settingsStore: SettingsStore, + private val privatePaykitCacheStore: PrivatePaykitCacheStore, private val httpClient: HttpClient, ) { companion object { @@ -278,6 +280,8 @@ class PubkyRepo @Inject constructor( runCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, sessionSecret) + settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) } + privatePaykitCacheStore.update { it.copy(profileRecoveryPending = false) } notifyBackupStateChanged() pk @@ -887,9 +891,8 @@ class PubkyRepo @Inject constructor( // region Auth approval suspend fun hasSecretKey(): Boolean = runCatching { - withContext(ioDispatcher) { - !keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrEmpty() - } + val publicKey = _publicKey.value ?: return@runCatching false + managedSecretKeyFor(publicKey) != null }.getOrDefault(false) suspend fun approveAuth(authUrl: String): Result = runCatching { diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index 75ce1fb42..2e92afccb 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -1,5 +1,6 @@ package to.bitkit.repositories +import androidx.annotation.VisibleForTesting import com.synonym.bitkitcore.Scanner import com.synonym.bitkitcore.validateBitcoinAddress import kotlinx.coroutines.CoroutineDispatcher @@ -11,6 +12,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.Network import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore @@ -35,6 +37,7 @@ import to.bitkit.di.json as appJson sealed class PublicPaykitError(message: String) : AppError(message) { data object InvalidPayload : PublicPaykitError("Invalid Paykit payment endpoint payload") data object NoSupportedEndpoint : PublicPaykitError("No supported public payment endpoint is available") + data object RouteHintsUnavailable : PublicPaykitError("Reachable Lightning payment endpoint is not available yet") data object SessionNotActive : PublicPaykitError("No active Paykit session") data object WalletNotReady : PublicPaykitError("Wallet is not ready to publish Paykit endpoints") } @@ -79,6 +82,15 @@ class PublicPaykitRepo @Inject constructor( private val publicBolt11Expiry = 24.hours private val publicBolt11RefreshWindow = 30.minutes + @VisibleForTesting + internal var lightningRouteHintsValidator: ((String) -> Boolean)? = null + + fun isLightningPaymentOptionEnabled(settings: SettingsData): Boolean = + settings.publicPaykitLightningEnabled + + fun isOnchainPaymentOptionEnabled(settings: SettingsData): Boolean = + settings.publicPaykitOnchainEnabled + fun parseEndpoint(methodId: String, endpointData: String): Endpoint? { if (!methodIdPattern.matches(methodId)) return null @@ -104,6 +116,12 @@ class PublicPaykitRepo @Inject constructor( return payloadJson.encodeToString(PaymentEndpointPayload(value = trimmedValue)) } + fun hasLightningRouteHints(bolt11: String): Boolean = + lightningRouteHintsValidator?.invoke(bolt11) + ?: runCatching { + Bolt11Invoice.fromStr(bolt11).routeHints().any { it.isNotEmpty() } + }.getOrDefault(false) + fun paymentRequest(endpoints: List): String { val sortedEndpoints = endpoints.sortedBy { payablePreferenceOrder.indexOf(it.methodId) } val lightning = sortedEndpoints.firstOrNull { it.methodId == MethodId.Bolt11 } @@ -171,11 +189,13 @@ class PublicPaykitRepo @Inject constructor( suspend fun syncCurrentPublishedEndpoints( forceRefreshLightning: Boolean = false, + requireEndpoint: Boolean = false, ): Result = withContext(ioDispatcher) { runCatching { val desired = buildWalletEndpoints( refresh = false, forceRefreshLightning = forceRefreshLightning, + requireEndpoint = requireEndpoint, ) applyPublishedEndpoints(desired) } @@ -247,7 +267,12 @@ class PublicPaykitRepo @Inject constructor( private suspend fun buildWalletEndpoints( refresh: Boolean, forceRefreshLightning: Boolean = false, + requireEndpoint: Boolean = true, ): List { + val settings = settingsStore.data.first() + val includeLightning = isLightningPaymentOptionEnabled(settings) + val includeOnchain = isOnchainPaymentOptionEnabled(settings) + if (refresh) { lightningRepo.executeWhenNodeRunning( operationName = "sync public Paykit endpoints", @@ -255,14 +280,20 @@ class PublicPaykitRepo @Inject constructor( Result.success(Unit) }.getOrThrow() } - walletRepo.refreshReusableReceiveAddressIfReserved().getOrThrow() + if (includeOnchain) { + walletRepo.refreshReusableReceiveAddressIfReserved().getOrThrow() + } val state = walletRepo.walletState.value val endpoints = mutableListOf() - buildPublicBolt11Endpoint(forceRefreshLightning)?.let { endpoints += it } + if (includeLightning) { + buildPublicBolt11Endpoint(forceRefreshLightning)?.let { endpoints += it } + } else { + clearPublicBolt11Metadata() + } val onchainAddress = state.onchainAddress - if (onchainAddress.isNotBlank()) { + if (includeOnchain && onchainAddress.isNotBlank()) { val methodId = onchainMethodId(onchainAddress) endpoints += Endpoint( methodId = methodId, @@ -271,7 +302,7 @@ class PublicPaykitRepo @Inject constructor( ) } - if (endpoints.isEmpty()) throw PublicPaykitError.NoSupportedEndpoint + if (endpoints.isEmpty() && requireEndpoint) throw PublicPaykitError.NoSupportedEndpoint return endpoints } @@ -288,11 +319,15 @@ class PublicPaykitRepo @Inject constructor( cachedBolt11.isNotBlank() && !settings.shouldRefreshPublicBolt11(clock.now().toEpochMilliseconds()) if (shouldReuseCachedBolt11) { - return Endpoint( - methodId = MethodId.Bolt11, - value = cachedBolt11, - rawPayload = serializePayload(cachedBolt11), - ) + if (!hasLightningRouteHints(cachedBolt11)) { + clearPublicBolt11Metadata() + } else { + return Endpoint( + methodId = MethodId.Bolt11, + value = cachedBolt11, + rawPayload = serializePayload(cachedBolt11), + ) + } } val bolt11 = lightningRepo.createInvoice( @@ -302,6 +337,10 @@ class PublicPaykitRepo @Inject constructor( ).getOrThrow() val invoice = (coreService.decode(bolt11) as? Scanner.Lightning)?.invoice ?: throw PublicPaykitError.InvalidPayload + if (!hasLightningRouteHints(bolt11)) { + clearPublicBolt11Metadata() + return null + } val expiresAtMillis = clock.now().plus(publicBolt11Expiry).toEpochMilliseconds() settingsStore.update { diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 1d0b09833..ebcbeb622 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -121,6 +121,7 @@ import to.bitkit.ui.screens.transfer.external.LnurlChannelScreen import to.bitkit.ui.screens.wallets.HomeScreen import to.bitkit.ui.screens.wallets.SavingsWalletScreen import to.bitkit.ui.screens.wallets.SpendingWalletScreen +import to.bitkit.ui.screens.wallets.activity.ActivityAssignContactScreen import to.bitkit.ui.screens.wallets.activity.ActivityDetailScreen import to.bitkit.ui.screens.wallets.activity.ActivityExploreScreen import to.bitkit.ui.screens.wallets.activity.AllActivityScreen @@ -174,6 +175,7 @@ import to.bitkit.ui.settings.lightning.ChannelDetailScreen import to.bitkit.ui.settings.lightning.CloseConnectionScreen import to.bitkit.ui.settings.lightning.LightningConnectionsScreen import to.bitkit.ui.settings.lightning.LightningConnectionsViewModel +import to.bitkit.ui.settings.paymentPreference.PaymentPreferenceScreen import to.bitkit.ui.settings.pin.PinManagementScreen import to.bitkit.ui.settings.quickPay.QuickPayIntroScreen import to.bitkit.ui.settings.quickPay.QuickPaySettingsScreen @@ -1185,6 +1187,11 @@ private fun NavGraphBuilder.generalSettingsSubScreens(navController: NavHostCont onBack = { navController.popBackStack() }, ) } + composableWithDefaultTransitions { + PaymentPreferenceScreen( + onBack = { navController.popBackStack() }, + ) + } composableWithDefaultTransitions { BackgroundPaymentsIntroScreen( @@ -1338,6 +1345,7 @@ private fun NavGraphBuilder.activityItem( listViewModel = activityListViewModel, route = it.toRoute(), onExploreClick = { id -> navController.navigateToActivityExplore(id) }, + onAssignContactClick = { id -> navController.navigateTo(Routes.ActivityAssignContact(id)) }, onChannelClick = { channelId -> navController.navigateTo(Routes.ChannelDetail(channelId)) }, @@ -1345,6 +1353,13 @@ private fun NavGraphBuilder.activityItem( onCloseClick = { navController.navigateToHome() }, ) } + composableWithDefaultTransitions { + val route = it.toRoute() + ActivityAssignContactScreen( + activityId = route.id, + onBackClick = { navController.popBackStack() }, + ) + } composableWithDefaultTransitions { ActivityExploreScreen( route = it.toRoute(), @@ -1715,6 +1730,8 @@ fun NavController.navigateToLogDetail(fileName: String) = navigateTo(Routes.LogD fun NavController.navigateToTransactionSpeedSettings() = navigateTo(Routes.TransactionSpeedSettings) +fun NavController.navigateToPaymentPreferenceSettings() = navigateTo(Routes.PaymentPreferenceSettings) + fun NavController.navigateToCustomFeeSettings() = navigateTo(Routes.CustomFeeSettings) fun NavController.navigateToWidgetsSettings() = navigateTo(Routes.WidgetsSettings) @@ -1748,6 +1765,9 @@ sealed interface Routes { @Serializable data object TransactionSpeedSettings : Routes + @Serializable + data object PaymentPreferenceSettings : Routes + @Serializable data object WidgetsSettings : Routes @@ -1906,6 +1926,9 @@ sealed interface Routes { @Serializable data class ActivityDetail(val id: String) : Routes + @Serializable + data class ActivityAssignContact(val id: String) : Routes + @Serializable data class ActivityExplore(val id: String) : Routes diff --git a/app/src/main/java/to/bitkit/ui/components/PubkyContactAvatar.kt b/app/src/main/java/to/bitkit/ui/components/PubkyContactAvatar.kt new file mode 100644 index 000000000..dac95d817 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/components/PubkyContactAvatar.kt @@ -0,0 +1,46 @@ +package to.bitkit.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import to.bitkit.models.PubkyProfile +import to.bitkit.ui.theme.Colors + +@Composable +fun PubkyContactAvatar( + profile: PubkyProfile, + modifier: Modifier = Modifier, + size: Dp = 48.dp, + testTag: String = "PubkyContactAvatar", +) { + if (profile.imageUrl != null) { + PubkyImage( + uri = profile.imageUrl, + size = size, + modifier = modifier.testTag(testTag), + ) + return + } + + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .size(size) + .clip(CircleShape) + .background(Colors.White10) + .testTag(testTag) + ) { + BodySSB( + text = profile.name.firstOrNull()?.uppercase().orEmpty(), + color = Colors.White, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/components/settings/SettingsSwitchRow.kt b/app/src/main/java/to/bitkit/ui/components/settings/SettingsSwitchRow.kt index 6371a5b8a..8012049e0 100644 --- a/app/src/main/java/to/bitkit/ui/components/settings/SettingsSwitchRow.kt +++ b/app/src/main/java/to/bitkit/ui/components/settings/SettingsSwitchRow.kt @@ -34,6 +34,7 @@ fun SettingsSwitchRow( isChecked: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, + enabled: Boolean = true, subtitle: String? = null, iconRes: Int? = null, iconTint: Color = Color.Unspecified, @@ -43,6 +44,7 @@ fun SettingsSwitchRow( title = title, isChecked = isChecked, onClick = onClick, + enabled = enabled, subtitle = subtitle, colors = colors, icon = if (iconRes != null) { @@ -69,6 +71,7 @@ fun SettingsSwitchRow( icon: @Composable () -> Unit, onClick: () -> Unit, modifier: Modifier = Modifier, + enabled: Boolean = true, subtitle: String? = null, colors: SwitchColors = AppSwitchDefaults.colors ) { @@ -76,6 +79,7 @@ fun SettingsSwitchRow( title = title, isChecked = isChecked, onClick = onClick, + enabled = enabled, subtitle = subtitle, colors = colors, icon = { @@ -92,6 +96,7 @@ private fun SettingsSwitchRowCore( isChecked: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, + enabled: Boolean = true, subtitle: String? = null, icon: (@Composable () -> Unit)? = null, colors: SwitchColors = AppSwitchDefaults.colors @@ -103,7 +108,7 @@ private fun SettingsSwitchRowCore( modifier = Modifier .fillMaxWidth() .heightIn(min = 52.dp) - .clickableAlpha { onClick() } + .clickableAlpha(enabled = enabled) { onClick() } ) { if (icon != null) { icon() @@ -124,6 +129,7 @@ private fun SettingsSwitchRowCore( Switch( checked = isChecked, onCheckedChange = null, // handled by parent + enabled = enabled, colors = colors, ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityScreen.kt index 1e462752e..80d8c507f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityScreen.kt @@ -128,6 +128,7 @@ private fun ContactActivityList( onEmptyActivityRowClick = {}, contentPadding = PaddingValues(top = 0.dp), activityTestTagPrefix = "ContactActivity", + showContactAvatar = false, titleProvider = { activity -> name?.let { val titleRes = if (activity.isSent()) { diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsScreen.kt index e4ebffd09..e792ef35b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsScreen.kt @@ -1,6 +1,5 @@ package to.bitkit.ui.screens.contacts -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -11,7 +10,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -21,7 +19,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -40,7 +37,7 @@ import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.GradientCircularProgressIndicator import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton -import to.bitkit.ui.components.PubkyImage +import to.bitkit.ui.components.PubkyContactAvatar import to.bitkit.ui.components.SearchInput import to.bitkit.ui.components.Text13Up import to.bitkit.ui.components.VerticalSpacer @@ -212,7 +209,7 @@ private fun ContactRow( .clickableAlpha(onClick = onClick) .padding(horizontal = 16.dp, vertical = 12.dp) ) { - ContactAvatar(profile = profile) + PubkyContactAvatar(profile = profile) Column( verticalArrangement = Arrangement.spacedBy(4.dp), @@ -234,26 +231,6 @@ private fun ContactRow( } } -@Composable -private fun ContactAvatar(profile: PubkyProfile) { - if (profile.imageUrl != null) { - PubkyImage(uri = profile.imageUrl, size = 48.dp) - } else { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background(Colors.White10) - ) { - BodySSB( - text = profile.name.firstOrNull()?.uppercase().orEmpty(), - color = Colors.White, - ) - } - } -} - @Composable private fun LoadingState() { Box( diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt index eaf7bb7d2..e4eb21770 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt @@ -228,7 +228,7 @@ class EditProfileViewModel @Inject constructor( viewModelScope.launch { _uiState.update { it.copy(showDeleteFailureDialog = false, isSaving = true) } privatePaykitRepo.removePublishedEndpointsBestEffort(TAG) - privatePaykitRepo.closeAndClear() + privatePaykitRepo.closeAndClear(markProfileRecoveryPending = true) pubkyRepo.signOut() .onSuccess { _uiState.update { it.copy(isSaving = false) } @@ -255,7 +255,7 @@ class EditProfileViewModel @Inject constructor( ) } privatePaykitRepo.removePublishedEndpointsBestEffort(TAG) - privatePaykitRepo.closeAndClear() + privatePaykitRepo.closeAndClear(markProfileRecoveryPending = true) pubkyRepo.deleteProfileWithSessionRetry() .onSuccess { _uiState.update { it.copy(isSaving = false) } diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt index 248c03403..8b682e8c5 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt @@ -42,9 +42,10 @@ class PayContactsViewModel @Inject constructor( init { viewModelScope.launch { val settings = settingsStore.data.first() + val hasLocalSecretKey = pubkyRepo.hasSecretKey() _uiState.update { it.copy( - isPaymentSharingEnabled = resolvedSharingDefault(settings), + isPaymentSharingEnabled = resolvedSharingDefault(settings, hasLocalSecretKey), ) } } @@ -73,7 +74,7 @@ class PayContactsViewModel @Inject constructor( } .onFailure { val settings = settingsStore.data.first() - val persistedValue = resolvedSharingDefault(settings) + val persistedValue = resolvedSharingDefault(settings, pubkyRepo.hasSecretKey()) ToastEventBus.send( type = Toast.ToastType.ERROR, title = context.getString(R.string.common__error), @@ -93,55 +94,78 @@ class PayContactsViewModel @Inject constructor( publicPaykitRepo.syncPublishedEndpoints(publish = true) .onFailure { return Result.failure(it) } - privatePaykitRepo.setContactSharingCleanupPending(false) - .onFailure { - publicPaykitRepo.syncPublishedEndpoints(publish = false) - return Result.failure(it) - } + val canUsePrivateContactPayments = pubkyRepo.hasSecretKey() + if (canUsePrivateContactPayments) { + privatePaykitRepo.setContactSharingCleanupPending(false) + .onFailure { + publicPaykitRepo.syncPublishedEndpoints(publish = false) + return Result.failure(it) + } + } runCatching { settingsStore.update { it.copy( hasConfirmedPublicPaykitEndpoints = true, sharesPublicPaykitEndpoints = true, + sharesPrivatePaykitEndpoints = canUsePrivateContactPayments, ) } }.onFailure { return Result.failure(it) } - privatePaykitRepo.prepareSavedContacts(contacts) + if (canUsePrivateContactPayments) { + privatePaykitRepo.prepareSavedContacts(contacts) + } return Result.success(Unit) } private suspend fun disableContactPayments(contacts: List): Result { + val previous = settingsStore.data.first() runCatching { settingsStore.update { it.copy( hasConfirmedPublicPaykitEndpoints = true, sharesPublicPaykitEndpoints = false, + sharesPrivatePaykitEndpoints = false, ) } }.onFailure { return Result.failure(it) } - var cleanupError: Throwable? = null + var publicCleanupError: Throwable? = null + var privateCleanupError: Throwable? = null publicPaykitRepo.syncPublishedEndpoints(publish = false) - .onFailure { cleanupError = it } + .onFailure { publicCleanupError = it } privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) - .onFailure { - if (cleanupError == null) cleanupError = it + .onFailure { privateCleanupError = it } + + publicCleanupError?.let { error -> + runCatching { + settingsStore.update { settings -> + settings.copy(sharesPublicPaykitEndpoints = previous.sharesPublicPaykitEndpoints) + } + }.onFailure { rollbackError -> + error.addSuppressed(rollbackError) } + } + val cleanupError = publicCleanupError ?: privateCleanupError + publicCleanupError?.let { publicError -> + privateCleanupError?.let { privateError -> publicError.addSuppressed(privateError) } + } cleanupError?.let { - privatePaykitRepo.setContactSharingCleanupPending(true) - .onFailure { markerError -> - it.addSuppressed(markerError) - return Result.failure(it) - } + if (privateCleanupError != null) { + privatePaykitRepo.setContactSharingCleanupPending(true) + .onFailure { markerError -> + it.addSuppressed(markerError) + return Result.failure(it) + } + } return Result.failure(it) } @@ -159,8 +183,10 @@ class PayContactsViewModel @Inject constructor( else -> context.getString(R.string.common__error_body) } - private fun resolvedSharingDefault(settings: SettingsData): Boolean = - settings.sharesPublicPaykitEndpoints || !settings.hasConfirmedPublicPaykitEndpoints + private fun resolvedSharingDefault(settings: SettingsData, hasLocalSecretKey: Boolean): Boolean = + settings.sharesPublicPaykitEndpoints || + (settings.sharesPrivatePaykitEndpoints && hasLocalSecretKey) || + !settings.hasConfirmedPublicPaykitEndpoints } @Immutable diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt index 0408d847e..49aaad279 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt @@ -78,7 +78,7 @@ class ProfileViewModel @Inject constructor( _isSigningOut.update { true } _showSignOutDialog.update { false } privatePaykitRepo.removePublishedEndpointsBestEffort(TAG) - privatePaykitRepo.closeAndClear() + privatePaykitRepo.closeAndClear(markProfileRecoveryPending = true) pubkyRepo.signOut() .onSuccess { _effects.emit(ProfileEffect.SignedOut) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityAssignContactScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityAssignContactScreen.kt new file mode 100644 index 000000000..6e543f47c --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityAssignContactScreen.kt @@ -0,0 +1,284 @@ +package to.bitkit.ui.screens.wallets.activity + +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import to.bitkit.R +import to.bitkit.di.BgDispatcher +import to.bitkit.models.PubkyProfile +import to.bitkit.models.Toast +import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.PubkyRepo +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.GradientCircularProgressIndicator +import to.bitkit.ui.components.PubkyContactAvatar +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.utils.Logger +import javax.inject.Inject + +@HiltViewModel +class ActivityAssignContactViewModel @Inject constructor( + @ApplicationContext private val context: Context, + @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + private val activityRepo: ActivityRepo, + private val pubkyRepo: PubkyRepo, +) : ViewModel() { + private val _uiState = MutableStateFlow(ActivityAssignContactUiState()) + val uiState = _uiState.asStateFlow() + + private val _effects = MutableSharedFlow(extraBufferCapacity = 1) + val effects = _effects.asSharedFlow() + + init { + viewModelScope.launch { + pubkyRepo.contacts.collect { contacts -> + _uiState.update { + it.copy( + contacts = contacts.sortedBy { contact -> contact.name.lowercase() }.toImmutableList(), + isLoading = pubkyRepo.isLoadingContacts.value, + ) + } + } + } + viewModelScope.launch { + pubkyRepo.isLoadingContacts.collect { isLoading -> + _uiState.update { it.copy(isLoading = isLoading) } + } + } + refresh() + } + + fun refresh() { + viewModelScope.launch { pubkyRepo.loadContacts() } + } + + fun assignContact(activityId: String, publicKey: String) { + if (_uiState.value.selectedContactKey != null) return + + viewModelScope.launch(bgDispatcher) { + _uiState.update { it.copy(selectedContactKey = publicKey) } + activityRepo.setContact( + contactPublicKey = publicKey, + forPaymentId = activityId, + syncLdkPayments = false, + ).onSuccess { + _effects.emit(ActivityAssignContactEffect.Close) + }.onFailure { + Logger.error("Failed to assign contact for activity '$activityId'", it, context = TAG) + _uiState.update { state -> state.copy(selectedContactKey = null) } + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = context.getString(R.string.common__error_body), + ) + } + } + } + + private companion object { + const val TAG = "ActivityAssignContactViewModel" + } +} + +@Stable +data class ActivityAssignContactUiState( + val contacts: ImmutableList = persistentListOf(), + val isLoading: Boolean = false, + val selectedContactKey: String? = null, +) + +sealed interface ActivityAssignContactEffect { + data object Close : ActivityAssignContactEffect +} + +@Composable +fun ActivityAssignContactScreen( + activityId: String, + onBackClick: () -> Unit, + viewModel: ActivityAssignContactViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(activityId) { + viewModel.effects.collect { + when (it) { + ActivityAssignContactEffect.Close -> onBackClick() + } + } + } + + ActivityAssignContactContent( + uiState = uiState, + onBackClick = onBackClick, + onContactClick = { viewModel.assignContact(activityId, it.publicKey) }, + ) +} + +@Composable +private fun ActivityAssignContactContent( + uiState: ActivityAssignContactUiState, + onBackClick: () -> Unit = {}, + onContactClick: (PubkyProfile) -> Unit = {}, +) { + ScreenColumn(modifier = Modifier.testTag("AssignActivityContactScreen")) { + AppTopBar( + titleText = stringResource(R.string.wallet__activity_assign_contact_title), + onBackClick = onBackClick, + actions = { DrawerNavIcon() }, + ) + + when { + uiState.isLoading && uiState.contacts.isEmpty() -> { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + GradientCircularProgressIndicator() + } + } + + uiState.contacts.isEmpty() -> { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .padding(32.dp) + ) { + BodyM( + text = stringResource(R.string.wallet__activity_assign_contact_empty), + color = Colors.White64, + ) + } + } + + else -> AssignContactList( + contacts = uiState.contacts, + selectedContactKey = uiState.selectedContactKey, + onContactClick = onContactClick, + ) + } + } +} + +@Composable +private fun AssignContactList( + contacts: ImmutableList, + selectedContactKey: String?, + onContactClick: (PubkyProfile) -> Unit, +) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + item { + Caption13Up( + text = stringResource(R.string.contacts__nav_title), + color = Colors.White64, + modifier = Modifier.padding(top = 16.dp, bottom = 16.dp) + ) + HorizontalDivider(color = Colors.White10) + } + + items(contacts, key = { it.publicKey }) { contact -> + AssignActivityContactRow( + contact = contact, + enabled = selectedContactKey == null, + onClickContact = { onContactClick(contact) }, + modifier = Modifier.testTag("AssignActivityContact_${contact.publicKey}") + ) + HorizontalDivider(color = Colors.White10) + } + } +} + +@Composable +private fun AssignActivityContactRow( + contact: PubkyProfile, + enabled: Boolean, + onClickContact: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier + .fillMaxWidth() + .clickableAlpha(enabled = enabled) { onClickContact() } + .padding(vertical = 24.dp) + ) { + PubkyContactAvatar(profile = contact) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.weight(1f) + ) { + BodyS( + text = contact.truncatedPublicKey, + color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + BodySSB( + text = contact.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + ActivityAssignContactContent( + uiState = ActivityAssignContactUiState( + contacts = persistentListOf(PubkyProfile.placeholder("pubky1")), + ), + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt index 278a316ee..ea4778aed 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.painter.Painter @@ -37,6 +38,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -53,6 +55,7 @@ import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf import to.bitkit.R +import to.bitkit.ext.contact import to.bitkit.ext.create import to.bitkit.ext.ellipsisMiddle import to.bitkit.ext.isSent @@ -63,6 +66,8 @@ import to.bitkit.ext.toActivityItemDate import to.bitkit.ext.toActivityItemTime import to.bitkit.ext.totalValue import to.bitkit.models.FeeRate.Companion.getFeeShortDescription +import to.bitkit.models.PubkyProfile +import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.Toast import to.bitkit.ui.Routes import to.bitkit.ui.appViewModel @@ -74,6 +79,7 @@ import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.MoneySSB import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.PubkyContactAvatar import to.bitkit.ui.components.TagButton import to.bitkit.ui.components.Title import to.bitkit.ui.scaffold.AppTopBar @@ -86,7 +92,7 @@ import to.bitkit.ui.shared.animations.BalanceAnimations import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.sheets.BoostTransactionSheet -import to.bitkit.ui.sheets.ComingSoonSheet +import to.bitkit.ui.theme.AppShapes import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.copyToClipboard @@ -101,6 +107,7 @@ fun ActivityDetailScreen( detailViewModel: ActivityDetailViewModel = hiltViewModel(), route: Routes.ActivityDetail, onExploreClick: (String) -> Unit, + onAssignContactClick: (String) -> Unit, onBackClick: () -> Unit, onCloseClick: () -> Unit, onChannelClick: ((String) -> Unit)? = null, @@ -177,9 +184,9 @@ fun ActivityDetailScreen( val copyToastTitle = stringResource(R.string.common__copied) val tags by detailViewModel.tags.collectAsStateWithLifecycle() + val contacts by listViewModel.contacts.collectAsStateWithLifecycle() val boostSheetVisible by detailViewModel.boostSheetVisible.collectAsStateWithLifecycle() var showAddTagSheet by remember { mutableStateOf(false) } - var showAssignSheet by remember { mutableStateOf(false) } var isCpfpChild by remember { mutableStateOf(false) } var boostTxDoesExist by remember { mutableStateOf>(persistentMapOf()) } @@ -205,6 +212,7 @@ fun ActivityDetailScreen( } val context = LocalContext.current + val assignedContact = assignedContactProfile(item, contacts) val blocktankInfo by blocktankViewModel?.info?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(null) } @@ -226,10 +234,12 @@ fun ActivityDetailScreen( ) ActivityDetailContent( item = item, + assignedContact = assignedContact, tags = tags, onRemoveTag = { detailViewModel.removeTag(it) }, onAddTagClick = { showAddTagSheet = true }, - onAssignClick = { showAssignSheet = true }, + onAssignClick = { onAssignContactClick(item.rawId()) }, + onDetachClick = { detailViewModel.detachContact() }, onClickBoost = detailViewModel::onClickBoost, onExploreClick = onExploreClick, onChannelClick = onChannelClick, @@ -296,13 +306,6 @@ fun ActivityDetailScreen( ) } } - - if (showAssignSheet) { - ComingSoonSheet( - onWalletOverviewClick = onCloseClick, - onBack = { showAssignSheet = false }, - ) - } } } } @@ -312,10 +315,12 @@ fun ActivityDetailScreen( @Composable private fun ActivityDetailContent( item: Activity, + assignedContact: PubkyProfile?, tags: ImmutableList, onRemoveTag: (String) -> Unit, onAddTagClick: () -> Unit, onAssignClick: () -> Unit, + onDetachClick: () -> Unit, onClickBoost: () -> Unit, onExploreClick: (String) -> Unit, onChannelClick: ((String) -> Unit)?, @@ -394,8 +399,8 @@ private fun ActivityDetailContent( ActivityIcon( activity = item, size = 48.dp, - isCpfpChild = isCpfpChild - ) // TODO Display the user avatar when selfSend + isCpfpChild = isCpfpChild, + ) } Spacer(modifier = Modifier.height(16.dp)) @@ -536,31 +541,11 @@ private fun ActivityDetailContent( } } - // Tags section - if (tags.isNotEmpty()) { - Column(modifier = Modifier.fillMaxWidth()) { - Caption13Up( - text = stringResource(R.string.wallet__tags), - color = Colors.White64, - modifier = Modifier.padding(top = 16.dp, bottom = 8.dp) - ) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.testTag("ActivityTags") - ) { - tags.forEach { tag -> - TagButton( - text = tag, - displayIconClose = true, - onClick = { onRemoveTag(tag) } - ) - } - } - Spacer(modifier = Modifier.height(16.dp)) - HorizontalDivider() - } - } + ContactTagsSection( + contact = assignedContact, + tags = tags, + onRemoveTag = onRemoveTag, + ) // Note section for Lightning payments with message if (item is Activity.Lightning && item.v1.message.isNotEmpty()) { @@ -611,15 +596,22 @@ private fun ActivityDetailContent( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth() ) { - @Suppress("ForbiddenComment") PrimaryButton( - text = stringResource(R.string.wallet__activity_assign), + text = stringResource( + if (assignedContact != null) { + R.string.wallet__activity_detach + } else { + R.string.wallet__activity_assign + } + ), size = ButtonSize.Small, - onClick = onAssignClick, + onClick = if (assignedContact != null) onDetachClick else onAssignClick, enabled = !isSelfSend, icon = { Icon( - painter = painterResource(R.drawable.ic_user_plus), + painter = painterResource( + if (assignedContact != null) R.drawable.ic_user_minus else R.drawable.ic_user_plus + ), contentDescription = null, tint = accentColor, modifier = Modifier.size(16.dp) @@ -733,6 +725,101 @@ private fun ActivityDetailContent( } } +@Composable +private fun ContactTagsSection( + contact: PubkyProfile?, + tags: ImmutableList, + onRemoveTag: (String) -> Unit, +) { + if (contact == null && tags.isEmpty()) return + + if (contact != null && tags.isNotEmpty()) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth() + ) { + ContactCell(contact = contact, modifier = Modifier.weight(1f)) + TagsCell(tags = tags, onRemoveTag = onRemoveTag, modifier = Modifier.weight(1f)) + } + } else if (contact != null) { + ContactCell(contact = contact, modifier = Modifier.fillMaxWidth()) + } else { + TagsCell(tags = tags, onRemoveTag = onRemoveTag, modifier = Modifier.fillMaxWidth()) + } +} + +@Composable +private fun ContactCell( + contact: PubkyProfile, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Caption13Up( + text = stringResource(R.string.wallet__activity_contact), + color = Colors.White64, + modifier = Modifier.padding(top = 16.dp, bottom = 8.dp) + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .padding(bottom = 16.dp) + .clip(AppShapes.small) + .background(Colors.Gray6) + .padding(horizontal = 8.dp, vertical = 6.dp) + .testTag("ActivityAssignedContact") + ) { + PubkyContactAvatar(profile = contact, size = 24.dp) + BodySSB( + text = contact.name, + color = Colors.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +private fun assignedContactProfile( + item: Activity, + contacts: List, +): PubkyProfile? { + val contactKey = item.contact() ?: return null + return contacts.firstOrNull { + PubkyPublicKeyFormat.matches(it.publicKey, contactKey) + } ?: PubkyProfile.placeholder(PubkyPublicKeyFormat.normalized(contactKey) ?: contactKey) +} + +@Composable +private fun TagsCell( + tags: ImmutableList, + onRemoveTag: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Caption13Up( + text = stringResource(R.string.wallet__tags), + color = Colors.White64, + modifier = Modifier.padding(top = 16.dp, bottom = 8.dp) + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.testTag("ActivityTags") + ) { + tags.forEach { tag -> + TagButton( + text = tag, + displayIconClose = true, + onClick = { onRemoveTag(tag) } + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + } +} + @Composable private fun StatusSection( item: Activity, @@ -889,10 +976,12 @@ private fun PreviewLightningSent() { message = "Thanks for paying at the bar. Here's my share.", ) ), + assignedContact = null, tags = persistentListOf("Lunch", "Drinks"), onRemoveTag = {}, onAddTagClick = {}, onAssignClick = {}, + onDetachClick = {}, onExploreClick = {}, onChannelClick = null, onCopy = {}, @@ -920,10 +1009,12 @@ private fun PreviewOnchain() { confirmTimestamp = (System.currentTimeMillis() / 1000).toULong(), ) ), + assignedContact = null, tags = persistentListOf(), onRemoveTag = {}, onAddTagClick = {}, onAssignClick = {}, + onDetachClick = {}, onExploreClick = {}, onChannelClick = null, onCopy = {}, @@ -952,10 +1043,12 @@ private fun PreviewSheetSmallScreen() { message = "Thanks for paying at the bar. Here's my share.", ) ), + assignedContact = null, tags = persistentListOf("Lunch", "Drinks"), onRemoveTag = {}, onAddTagClick = {}, onAssignClick = {}, + onDetachClick = {}, onExploreClick = {}, onChannelClick = null, onCopy = {}, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt index ddeed3e91..8522ca32e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt @@ -30,6 +30,8 @@ import to.bitkit.ext.isBoosting import to.bitkit.ext.isTransfer import to.bitkit.ext.paymentState import to.bitkit.ext.txType +import to.bitkit.models.PubkyProfile +import to.bitkit.ui.components.PubkyContactAvatar import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -39,6 +41,7 @@ fun ActivityIcon( modifier: Modifier = Modifier, size: Dp = 32.dp, isCpfpChild: Boolean = false, + contact: PubkyProfile? = null, ) { val isLightning = activity is Activity.Lightning val isBoosting = activity.isBoosting() @@ -57,6 +60,12 @@ fun ActivityIcon( ) } + contact != null -> PubkyContactAvatar( + profile = contact, + size = size, + testTag = "ActivityContactAvatar", + modifier = modifier + ) isLightning -> ActivityIconLightning(status, size, arrowIcon, modifier) else -> ActivityIconOnchain(activity, arrowIcon, size, modifier) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt index 98d8137b8..f74e38850 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt @@ -50,6 +50,7 @@ fun ActivityListGrouped( onAllActivityButtonClick: () -> Unit = {}, contentPadding: PaddingValues = PaddingValues(top = 20.dp), activityTestTagPrefix: String = "Activity", + showContactAvatar: Boolean = true, titleProvider: @Composable (Activity) -> String? = { null }, ) { val contacts by activityListViewModel?.contacts?.collectAsStateWithLifecycle() ?: remember { @@ -112,6 +113,7 @@ fun ActivityListGrouped( onClick = onActivityItemClick, testTag = "$activityTestTagPrefix-$index", title = titleProvider(item) ?: contactActivityTitle(item, contacts), + contact = if (showContactAvatar) contactForActivity(item, contacts) else null, ) VerticalSpacer(16.dp) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt index 8d17b90ed..74b38d8aa 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt @@ -47,6 +47,7 @@ fun ActivityListSimple( onClick = onActivityItemClick, testTag = "ActivityShort-$index", title = contactActivityTitle(item, contacts), + contact = contactForActivity(item, contacts), ) if (index < items.lastIndex) { VerticalSpacer(16.dp) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt index c57009e21..5fbfb9025 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt @@ -38,6 +38,7 @@ import to.bitkit.ext.totalValue import to.bitkit.ext.txType import to.bitkit.models.FeeRate.Companion.getFeeShortDescription import to.bitkit.models.PrimaryDisplay +import to.bitkit.models.PubkyProfile import to.bitkit.models.formatToModernDisplay import to.bitkit.repositories.CurrencyState import to.bitkit.ui.LocalCurrencies @@ -67,6 +68,7 @@ fun ActivityRow( onClick: (String) -> Unit, testTag: String, title: String? = null, + contact: PubkyProfile? = null, ) { val blocktankInfo by blocktankViewModel?.info?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(null) @@ -92,6 +94,9 @@ fun ActivityRow( val resolvedTitle = title.takeIf { shouldUseContactActivityTitle(item, status, isTransfer, isCpfpChild) } + val resolvedContact = contact.takeIf { + shouldUseContactActivityTitle(item, status, isTransfer, isCpfpChild) + } LaunchedEffect(item) { isCpfpChild = if (item is Activity.Onchain && activityListViewModel != null) { @@ -110,7 +115,7 @@ fun ActivityRow( .padding(16.dp) .testTag(testTag) ) { - ActivityIcon(activity = item, size = 40.dp, isCpfpChild = isCpfpChild) + ActivityIcon(activity = item, size = 40.dp, isCpfpChild = isCpfpChild, contact = resolvedContact) HorizontalSpacer(16.dp) Column( verticalArrangement = Arrangement.spacedBy(2.dp), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ContactActivityTitle.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ContactActivityTitle.kt index 283c2df8e..be5cc405d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ContactActivityTitle.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ContactActivityTitle.kt @@ -14,8 +14,7 @@ import to.bitkit.models.PubkyPublicKeyFormat @Composable fun contactActivityTitle(activity: Activity, contacts: ImmutableList): String? { val contactName = remember(activity, contacts) { - val contact = activity.contact() ?: return@remember null - contacts.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, contact) }?.name + contactForActivity(activity, contacts)?.name } ?: return null val titleRes = if (activity.isSent()) { @@ -25,3 +24,8 @@ fun contactActivityTitle(activity: Activity, contacts: ImmutableList): PubkyProfile? { + val contact = activity.contact() ?: return null + return contacts.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, contact) } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt index 292aeaca3..3f7853ef8 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt @@ -51,7 +51,6 @@ import to.bitkit.ui.components.SyncNodeView import to.bitkit.ui.components.Text13Up import to.bitkit.ui.components.UnitButton import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground @@ -148,8 +147,9 @@ fun SendAmountContent( else -> R.string.wallet__send_amount } - SheetTopBar( + SendContactTopBar( titleText = stringResource(titleRes), + contact = uiState.contactPaymentProfile, onBack = onBack, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt index 5da3886c0..8dd6a7140 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt @@ -63,6 +63,7 @@ import to.bitkit.R import to.bitkit.ext.commentAllowed import to.bitkit.ext.formatInvoiceExpiryRelative import to.bitkit.models.FeeRate +import to.bitkit.models.PubkyProfile import to.bitkit.models.TransactionSpeed import to.bitkit.ui.components.BalanceHeaderView import to.bitkit.ui.components.BiometricsView @@ -73,6 +74,7 @@ import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.NumberPadActionButton import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.PubkyContactAvatar import to.bitkit.ui.components.SendCell import to.bitkit.ui.components.SwipeToConfirm import to.bitkit.ui.components.SyncNodeView @@ -81,7 +83,6 @@ import to.bitkit.ui.components.TextInput import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.components.rememberMoneyText import to.bitkit.ui.scaffold.AppAlertDialog -import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.settingsViewModel import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.shared.modifiers.sheetHeight @@ -208,11 +209,12 @@ private fun Content( ) { val isLnurlPay = uiState.lnurl is LnurlParams.LnurlPay - SheetTopBar( + SendContactTopBar( titleText = when { isLnurlPay -> stringResource(R.string.wallet__lnurl_p_title) else -> stringResource(R.string.wallet__send_review) }, + contact = uiState.contactPaymentProfile, onBack = onBack.takeIf { canGoBack }, ) @@ -506,16 +508,20 @@ private fun OnChainDetails( caption = stringResource(R.string.wallet__send_to), modifier = Modifier.weight(1f) ) { - BodySSB( - text = uiState.address, - maxLines = 1, - overflow = TextOverflow.MiddleEllipsis, - modifier = Modifier - .height(28.dp) - .wrapContentHeight(Alignment.CenterVertically) - .clickableAlpha { onEvent(SendEvent.NavToAddress) } - .testTag("ReviewUri") - ) + if (uiState.contactPaymentProfile != null) { + ContactRecipient(profile = uiState.contactPaymentProfile) + } else { + BodySSB( + text = uiState.address, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + modifier = Modifier + .height(28.dp) + .wrapContentHeight(Alignment.CenterVertically) + .clickableAlpha { onEvent(SendEvent.NavToAddress) } + .testTag("ReviewUri") + ) + } } } @@ -626,16 +632,20 @@ private fun LightningDetails( caption = stringResource(R.string.wallet__send_to), modifier = Modifier.weight(1f) ) { - BodySSB( - text = destination, - maxLines = 1, - overflow = TextOverflow.MiddleEllipsis, - modifier = Modifier - .height(28.dp) - .wrapContentHeight(Alignment.CenterVertically) - .clickableAlpha { onEvent(SendEvent.NavToAddress) } - .testTag("ReviewUri") - ) + if (uiState.contactPaymentProfile != null) { + ContactRecipient(profile = uiState.contactPaymentProfile) + } else { + BodySSB( + text = destination, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + modifier = Modifier + .height(28.dp) + .wrapContentHeight(Alignment.CenterVertically) + .clickableAlpha { onEvent(SendEvent.NavToAddress) } + .testTag("ReviewUri") + ) + } } } @@ -733,6 +743,27 @@ private fun LightningDetails( } } +@Composable +private fun ContactRecipient( + profile: PubkyProfile, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .padding(vertical = 2.dp) + .testTag("ReviewContactRecipient") + ) { + PubkyContactAvatar(profile = profile, size = 24.dp) + BodySSB( + text = profile.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + @Composable private fun LnurlPayDetails( uiState: SendUiState, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreen.kt new file mode 100644 index 000000000..1acbbc9fe --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreen.kt @@ -0,0 +1,178 @@ +package to.bitkit.ui.screens.wallets.send + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import to.bitkit.R +import to.bitkit.models.PubkyProfile +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.GradientCircularProgressIndicator +import to.bitkit.ui.components.PubkyContactAvatar +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors + +@Composable +fun SendContactSelectScreen( + viewModel: SendContactSelectViewModel, + onBack: () -> Unit, + onOpenPayment: (String, String) -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.effects.collect { + when (it) { + is SendContactSelectEffect.OpenPayment -> onOpenPayment(it.paymentRequest, it.publicKey) + } + } + } + + SendContactSelectContent( + uiState = uiState, + onBack = onBack, + onContactClick = viewModel::payContact, + ) +} + +@Composable +private fun SendContactSelectContent( + uiState: SendContactSelectUiState, + onBack: () -> Unit = {}, + onContactClick: (String) -> Unit = {}, +) { + Column( + modifier = Modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + .testTag("SendContactSelectScreen") + ) { + SheetTopBar( + titleText = stringResource(R.string.wallet__send_contact_title), + onBack = onBack, + ) + + when { + uiState.isLoading && uiState.contacts.isEmpty() -> { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + GradientCircularProgressIndicator() + } + } + + uiState.contacts.isEmpty() -> { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .padding(32.dp) + ) { + BodyM( + text = stringResource(R.string.wallet__send_contact_empty), + color = Colors.White64, + ) + } + } + + else -> SendContactList( + contacts = uiState.contacts, + onContactClick = onContactClick, + ) + } + } +} + +@Composable +private fun SendContactList( + contacts: ImmutableList, + onContactClick: (String) -> Unit, +) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + items(contacts, key = { it.publicKey }) { contact -> + ContactRow( + profile = contact, + onClick = { onContactClick(contact.publicKey) }, + modifier = Modifier.testTag("SendContact_${contact.publicKey}") + ) + HorizontalDivider(color = Colors.White10) + } + } +} + +@Composable +private fun ContactRow( + profile: PubkyProfile, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier + .fillMaxWidth() + .clickableAlpha(onClick = onClick) + .padding(vertical = 12.dp) + ) { + PubkyContactAvatar(profile = profile) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.weight(1f) + ) { + BodyS( + text = profile.truncatedPublicKey, + color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + BodySSB( + text = profile.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Preview +@Composable +private fun Preview() { + AppThemeSurface { + SendContactSelectContent( + uiState = SendContactSelectUiState( + contacts = persistentListOf(PubkyProfile.placeholder("pubky1")), + ), + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt new file mode 100644 index 000000000..54a9c355c --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt @@ -0,0 +1,109 @@ +package to.bitkit.ui.screens.wallets.send + +import android.content.Context +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import to.bitkit.R +import to.bitkit.models.PubkyProfile +import to.bitkit.models.Toast +import to.bitkit.repositories.PrivatePaykitRepo +import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.PublicPaykitPaymentResult +import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.utils.Logger +import javax.inject.Inject + +@HiltViewModel +class SendContactSelectViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val pubkyRepo: PubkyRepo, + private val privatePaykitRepo: PrivatePaykitRepo, +) : ViewModel() { + private val _uiState = MutableStateFlow(SendContactSelectUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _effects = MutableSharedFlow(extraBufferCapacity = 1) + val effects = _effects.asSharedFlow() + + init { + viewModelScope.launch { + pubkyRepo.contacts.collect { contacts -> + _uiState.update { + it.copy( + contacts = contacts.sortedBy { contact -> contact.name.lowercase() }.toImmutableList(), + isLoading = pubkyRepo.isLoadingContacts.value, + ) + } + } + } + viewModelScope.launch { + pubkyRepo.isLoadingContacts.collect { isLoading -> + _uiState.update { it.copy(isLoading = isLoading) } + } + } + refresh() + } + + fun refresh() { + viewModelScope.launch { pubkyRepo.loadContacts() } + } + + fun payContact(publicKey: String) { + if (_uiState.value.isResolvingPayment) return + viewModelScope.launch { + _uiState.update { it.copy(isResolvingPayment = true) } + privatePaykitRepo.beginSavedContactPayment(publicKey) + .onSuccess { result -> + when (result) { + is PublicPaykitPaymentResult.Opened -> + _effects.emit(SendContactSelectEffect.OpenPayment(result.paymentRequest, publicKey)) + PublicPaykitPaymentResult.NoEndpoint -> + showPayError(R.string.slashtags__error_pay_empty_msg) + PublicPaykitPaymentResult.NotOpened -> + showPayError(R.string.slashtags__error_pay_not_opened_msg) + } + } + .onFailure { + Logger.warn("Failed to begin contact payment", it, context = TAG) + showPayError(R.string.slashtags__error_pay_not_opened_msg) + } + _uiState.update { it.copy(isResolvingPayment = false) } + } + } + + private suspend fun showPayError(messageRes: Int) { + ToastEventBus.send( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.slashtags__error_pay_title), + description = context.getString(messageRes), + ) + } + + private companion object { + const val TAG = "SendContactSelectViewModel" + } +} + +@Stable +data class SendContactSelectUiState( + val contacts: ImmutableList = persistentListOf(), + val isLoading: Boolean = false, + val isResolvingPayment: Boolean = false, +) + +sealed interface SendContactSelectEffect { + data class OpenPayment(val paymentRequest: String, val publicKey: String) : SendContactSelectEffect +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactTopBar.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactTopBar.kt new file mode 100644 index 000000000..18a39eb20 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactTopBar.kt @@ -0,0 +1,37 @@ +package to.bitkit.ui.screens.wallets.send + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import to.bitkit.models.PubkyProfile +import to.bitkit.ui.components.PubkyContactAvatar +import to.bitkit.ui.scaffold.SheetTopBar + +@Composable +fun SendContactTopBar( + titleText: String, + contact: PubkyProfile?, + modifier: Modifier = Modifier, + onBack: (() -> Unit)? = null, +) { + Box(modifier = modifier.fillMaxWidth()) { + SheetTopBar( + titleText = titleText, + onBack = onBack, + ) + if (contact != null) { + PubkyContactAvatar( + profile = contact, + size = 32.dp, + testTag = "SendContactHeaderAvatar", + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 16.dp) + ) + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt index bdea2aeb2..67e3f3ffe 100644 --- a/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt @@ -46,6 +46,7 @@ import to.bitkit.ui.navigateToDefaultUnitSettings import to.bitkit.ui.navigateToDevSettings import to.bitkit.ui.navigateToLanguageSettings import to.bitkit.ui.navigateToLocalCurrencySettings +import to.bitkit.ui.navigateToPaymentPreferenceSettings import to.bitkit.ui.navigateToPinManagement import to.bitkit.ui.navigateToQuickPaySettings import to.bitkit.ui.navigateToTagsSettings @@ -89,6 +90,7 @@ fun SettingsScreen( val quickPayIntroSeen by settings.quickPayIntroSeen.collectAsStateWithLifecycle() val bgPaymentsIntroSeen by settings.bgPaymentsIntroSeen.collectAsStateWithLifecycle() val notificationsGranted by settings.notificationsGranted.collectAsStateWithLifecycle() + val isPubkyAuthenticated by settings.isPubkyAuthenticated.collectAsStateWithLifecycle() val languageUiState by languageViewModel.uiState.collectAsStateWithLifecycle() // Security tab state @@ -123,6 +125,7 @@ fun SettingsScreen( tagCount = lastUsedTags.size, isQuickPayEnabled = isQuickPayEnabled, notificationsGranted = notificationsGranted, + isPubkyAuthenticated = isPubkyAuthenticated, ), securityState = SecurityTabState( isPinEnabled = isPinEnabled, @@ -150,6 +153,7 @@ fun SettingsScreen( SettingsEvent.WidgetsClick -> navController.navigateToWidgetsSettings() SettingsEvent.TagsClick -> navController.navigateToTagsSettings() SettingsEvent.TransactionSpeedClick -> navController.navigateToTransactionSpeedSettings() + SettingsEvent.PaymentPreferenceClick -> navController.navigateToPaymentPreferenceSettings() SettingsEvent.QuickPayClick -> navController.navigateToQuickPaySettings(quickPayIntroSeen) SettingsEvent.BgPaymentsClick -> { if (bgPaymentsIntroSeen || notificationsGranted) { @@ -321,6 +325,14 @@ private fun GeneralTabContent( onClick = { onEvent(SettingsEvent.TransactionSpeedClick) }, modifier = Modifier.testTag("TransactionSpeedSettings") ) + if (state.isPubkyAuthenticated) { + SettingsButtonRow( + title = stringResource(R.string.settings__payment_pref_title), + icon = { SettingsIcon(R.drawable.ic_coins) }, + onClick = { onEvent(SettingsEvent.PaymentPreferenceClick) }, + modifier = Modifier.testTag("PaymentPreferenceSettings") + ) + } SettingsButtonRow( title = stringResource(R.string.settings__quickpay__nav_title), icon = { SettingsIcon(R.drawable.ic_caret_double_right) }, @@ -618,6 +630,7 @@ sealed interface SettingsEvent { data object WidgetsClick : SettingsEvent data object TagsClick : SettingsEvent data object TransactionSpeedClick : SettingsEvent + data object PaymentPreferenceClick : SettingsEvent data object QuickPayClick : SettingsEvent data object BgPaymentsClick : SettingsEvent @@ -660,6 +673,7 @@ data class GeneralTabState( val tagCount: Int = 0, val isQuickPayEnabled: Boolean = false, val notificationsGranted: Boolean = false, + val isPubkyAuthenticated: Boolean = false, ) @Immutable diff --git a/app/src/main/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceScreen.kt b/app/src/main/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceScreen.kt new file mode 100644 index 000000000..520d07e4e --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceScreen.kt @@ -0,0 +1,145 @@ +package to.bitkit.ui.settings.paymentPreference + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import to.bitkit.R +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.components.settings.SectionHeader +import to.bitkit.ui.components.settings.SettingsSwitchRow +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors + +@Composable +fun PaymentPreferenceScreen( + onBack: () -> Unit, + viewModel: PaymentPreferenceViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + PaymentPreferenceContent( + uiState = uiState, + onBack = onBack, + onToggleLightning = { viewModel.setLightningEnabled(!uiState.lightningEnabled) }, + onToggleOnchain = { viewModel.setOnchainEnabled(!uiState.onchainEnabled) }, + onTogglePrivateContacts = { viewModel.setPrivateContactsEnabled(!uiState.privateContactsEnabled) }, + onTogglePublicContacts = { viewModel.setPublicContactsEnabled(!uiState.publicContactsEnabled) }, + ) +} + +@Composable +private fun PaymentPreferenceContent( + uiState: PaymentPreferenceUiState, + onBack: () -> Unit = {}, + onToggleLightning: () -> Unit = {}, + onToggleOnchain: () -> Unit = {}, + onTogglePrivateContacts: () -> Unit = {}, + onTogglePublicContacts: () -> Unit = {}, +) { + ScreenColumn { + AppTopBar( + titleText = stringResource(R.string.settings__payment_pref_title), + onBackClick = onBack, + actions = { DrawerNavIcon() }, + ) + + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()) + .testTag("PaymentPreferenceScreen") + ) { + BodyM( + text = stringResource(R.string.settings__payment_pref_header), + color = Colors.White64, + modifier = Modifier.padding(top = 32.dp, bottom = 16.dp) + ) + + SectionHeader( + title = stringResource(R.string.settings__payment_pref_options), + padding = PaddingValues.Zero, + ) + + SettingsSwitchRow( + title = stringResource(R.string.settings__payment_pref_lightning), + isChecked = uiState.lightningEnabled, + onClick = onToggleLightning, + enabled = !uiState.isUpdatingPaymentOptions && (!uiState.lightningEnabled || uiState.onchainEnabled), + modifier = Modifier.testTag("PaymentPreferenceLightning") + ) + SettingsSwitchRow( + title = stringResource(R.string.settings__payment_pref_onchain), + isChecked = uiState.onchainEnabled, + onClick = onToggleOnchain, + enabled = !uiState.isUpdatingPaymentOptions && (!uiState.onchainEnabled || uiState.lightningEnabled), + modifier = Modifier.testTag("PaymentPreferenceOnchain") + ) + + if (uiState.hasPubkyProfile) { + SectionHeader( + title = stringResource(R.string.settings__payment_pref_contacts), + padding = PaddingValues(top = 16.dp), + ) + + if (uiState.canUsePrivateContacts) { + SettingsSwitchRow( + title = stringResource(R.string.settings__payment_pref_private_contacts), + isChecked = uiState.privateContactsEnabled, + onClick = onTogglePrivateContacts, + enabled = !uiState.isUpdatingPrivateContacts, + modifier = Modifier.testTag("PaymentPreferencePrivateContacts") + ) + } + SettingsSwitchRow( + title = stringResource(R.string.settings__payment_pref_public_contacts), + isChecked = uiState.publicContactsEnabled, + onClick = onTogglePublicContacts, + enabled = !uiState.isUpdatingPublicContacts, + modifier = Modifier.testTag("PaymentPreferencePublicContacts") + ) + } + + VerticalSpacer(220.dp) + if (uiState.hasPubkyProfile) { + BodyS( + text = stringResource(R.string.settings__payment_pref_contacts_footer), + color = Colors.White64, + ) + } + VerticalSpacer(32.dp) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun Preview() { + AppThemeSurface { + PaymentPreferenceContent( + uiState = PaymentPreferenceUiState( + lightningEnabled = true, + onchainEnabled = true, + privateContactsEnabled = true, + publicContactsEnabled = true, + hasPubkyProfile = true, + canUsePrivateContacts = true, + ), + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModel.kt new file mode 100644 index 000000000..84b1cd05a --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModel.kt @@ -0,0 +1,229 @@ +package to.bitkit.ui.settings.paymentPreference + +import android.content.Context +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import to.bitkit.R +import to.bitkit.data.SettingsStore +import to.bitkit.models.Toast +import to.bitkit.repositories.PrivatePaykitRepo +import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.PublicPaykitError +import to.bitkit.repositories.PublicPaykitRepo +import to.bitkit.ui.shared.toast.ToastEventBus +import javax.inject.Inject + +@HiltViewModel +class PaymentPreferenceViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val settingsStore: SettingsStore, + private val publicPaykitRepo: PublicPaykitRepo, + private val privatePaykitRepo: PrivatePaykitRepo, + private val pubkyRepo: PubkyRepo, +) : ViewModel() { + private val _uiState = MutableStateFlow(PaymentPreferenceUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + viewModelScope.launch { + combine(settingsStore.data, pubkyRepo.isAuthenticated) { settings, isAuthenticated -> + settings to isAuthenticated + }.collect { (settings, isAuthenticated) -> + val canUsePrivateContacts = isAuthenticated && pubkyRepo.hasSecretKey() + if (!canUsePrivateContacts && settings.sharesPrivatePaykitEndpoints) { + settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) } + } + _uiState.update { + it.copy( + lightningEnabled = settings.publicPaykitLightningEnabled, + onchainEnabled = settings.publicPaykitOnchainEnabled, + privateContactsEnabled = settings.sharesPrivatePaykitEndpoints && canUsePrivateContacts, + publicContactsEnabled = settings.sharesPublicPaykitEndpoints, + hasPubkyProfile = isAuthenticated, + canUsePrivateContacts = canUsePrivateContacts, + ) + } + } + } + } + + fun setLightningEnabled(isEnabled: Boolean) { + updatePaymentMethod(lightningEnabled = isEnabled) + } + + fun setOnchainEnabled(isEnabled: Boolean) { + updatePaymentMethod(onchainEnabled = isEnabled) + } + + fun setPrivateContactsEnabled(isEnabled: Boolean) { + if (_uiState.value.isUpdatingPrivateContacts) return + if (isEnabled && !_uiState.value.hasPubkyProfile) { + viewModelScope.launch { showSyncError(PublicPaykitError.SessionNotActive) } + return + } + if (isEnabled && !_uiState.value.canUsePrivateContacts) { + viewModelScope.launch { showSyncError(PublicPaykitError.SessionNotActive) } + return + } + viewModelScope.launch { + _uiState.update { it.copy(isUpdatingPrivateContacts = true) } + val previous = settingsStore.data.first() + settingsStore.update { + it.copy( + hasConfirmedPublicPaykitEndpoints = true, + sharesPrivatePaykitEndpoints = isEnabled, + ) + } + + val result = if (isEnabled) { + privatePaykitRepo.enableSharingAndPrepareSavedContacts(contactPublicKeys()) + } else { + privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contactPublicKeys()) + } + + result.exceptionOrNull()?.let { + if (!isEnabled) { + privatePaykitRepo.setContactSharingCleanupPending(true) + } + if (isEnabled) { + settingsStore.update { settings -> + settings.copy(sharesPrivatePaykitEndpoints = previous.sharesPrivatePaykitEndpoints) + } + } + showSyncError(it) + } + _uiState.update { it.copy(isUpdatingPrivateContacts = false) } + } + } + + fun setPublicContactsEnabled(isEnabled: Boolean) { + if (_uiState.value.isUpdatingPublicContacts) return + if (isEnabled && !_uiState.value.hasPubkyProfile) { + viewModelScope.launch { showSyncError(PublicPaykitError.SessionNotActive) } + return + } + viewModelScope.launch { + _uiState.update { it.copy(isUpdatingPublicContacts = true) } + val previous = settingsStore.data.first() + settingsStore.update { + it.copy( + hasConfirmedPublicPaykitEndpoints = true, + sharesPublicPaykitEndpoints = isEnabled, + ) + } + + publicPaykitRepo.syncPublishedEndpoints(publish = isEnabled).exceptionOrNull()?.let { + settingsStore.update { settings -> + settings.copy(sharesPublicPaykitEndpoints = previous.sharesPublicPaykitEndpoints) + } + showSyncError(it) + } + _uiState.update { it.copy(isUpdatingPublicContacts = false) } + } + } + + private fun updatePaymentMethod( + lightningEnabled: Boolean = _uiState.value.lightningEnabled, + onchainEnabled: Boolean = _uiState.value.onchainEnabled, + ) { + if (_uiState.value.isUpdatingPaymentOptions) return + if (!lightningEnabled && !onchainEnabled) { + viewModelScope.launch { + ToastEventBus.send( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.common__error), + description = context.getString(R.string.settings__payment_pref_keep_one), + ) + } + return + } + + viewModelScope.launch { + _uiState.update { it.copy(isUpdatingPaymentOptions = true) } + val previous = settingsStore.data.first() + settingsStore.update { + it.copy( + publicPaykitLightningEnabled = lightningEnabled, + publicPaykitOnchainEnabled = onchainEnabled, + ) + } + + val result = refreshPublishedPreferences() + result.exceptionOrNull()?.let { + settingsStore.update { settings -> + settings.copy( + publicPaykitLightningEnabled = previous.publicPaykitLightningEnabled, + publicPaykitOnchainEnabled = previous.publicPaykitOnchainEnabled, + ) + } + refreshPublishedPreferences() + showSyncError(it) + } + _uiState.update { it.copy(isUpdatingPaymentOptions = false) } + } + } + + private suspend fun refreshPublishedPreferences(): Result = runCatching { + val settings = settingsStore.data.first() + if (settings.sharesPublicPaykitEndpoints) { + publicPaykitRepo.syncCurrentPublishedEndpoints( + forceRefreshLightning = true, + requireEndpoint = true, + ).getOrThrow() + } + if (settings.sharesPrivatePaykitEndpoints) { + if (pubkyRepo.hasSecretKey()) { + privatePaykitRepo.prepareSavedContacts( + publicKeys = contactPublicKeys(), + requireImmediatePublication = true, + ).getOrThrow() + } else { + settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) } + } + } + } + + private fun contactPublicKeys(): List = + pubkyRepo.contacts.value.map { it.publicKey } + + private suspend fun showSyncError(error: Throwable) { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = when (error) { + PublicPaykitError.InvalidPayload -> + context.getString(R.string.profile__pay_contacts_error_invalid_payload) + + PublicPaykitError.NoSupportedEndpoint -> + context.getString(R.string.profile__pay_contacts_error_no_endpoint) + + PublicPaykitError.SessionNotActive -> context.getString(R.string.profile__session_expired) + PublicPaykitError.WalletNotReady -> context.getString(R.string.profile__pay_contacts_error_wallet) + else -> context.getString(R.string.common__error_body) + }, + ) + } +} + +@Immutable +data class PaymentPreferenceUiState( + val lightningEnabled: Boolean = true, + val onchainEnabled: Boolean = true, + val privateContactsEnabled: Boolean = false, + val publicContactsEnabled: Boolean = false, + val hasPubkyProfile: Boolean = false, + val canUsePrivateContacts: Boolean = false, + val isUpdatingPaymentOptions: Boolean = false, + val isUpdatingPrivateContacts: Boolean = false, + val isUpdatingPublicContacts: Boolean = false, +) diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 4851adc8c..2386aae6c 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -38,6 +38,8 @@ import to.bitkit.ui.screens.wallets.send.SendAddressScreen import to.bitkit.ui.screens.wallets.send.SendAmountScreen import to.bitkit.ui.screens.wallets.send.SendCoinSelectionScreen import to.bitkit.ui.screens.wallets.send.SendConfirmScreen +import to.bitkit.ui.screens.wallets.send.SendContactSelectScreen +import to.bitkit.ui.screens.wallets.send.SendContactSelectViewModel import to.bitkit.ui.screens.wallets.send.SendErrorScreen import to.bitkit.ui.screens.wallets.send.SendFeeCustomScreen import to.bitkit.ui.screens.wallets.send.SendFeeRateScreen @@ -121,6 +123,7 @@ fun SendSheet( is SendEffect.NavigateToFee -> navController.navigateTo(SendRoute.FeeRate) is SendEffect.NavigateToFeeCustom -> navController.navigateTo(SendRoute.FeeCustom) is SendEffect.NavigateToComingSoon -> navController.navigateTo(SendRoute.ComingSoon) + is SendEffect.NavigateToContacts -> navController.navigateTo(SendRoute.ContactSelect) is SendEffect.NavigateToPending -> navController.navigateTo( SendRoute.Pending(it.paymentHash, it.amount) ) { popUpTo(startDestination) { inclusive = true } } @@ -145,6 +148,18 @@ fun SendSheet( onEvent = { appViewModel.setSendEvent(it) }, ) } + composableWithDefaultTransitions { + SendContactSelectScreen( + viewModel = hiltViewModel(), + onBack = { + appViewModel.clearActiveContactPaymentContext() + navController.popBackStack() + }, + onOpenPayment = { paymentRequest, publicKey -> + appViewModel.openContactPayment(paymentRequest, publicKey) + }, + ) + } composableWithDefaultTransitions { val uiState by appViewModel.sendUiState.collectAsStateWithLifecycle() val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle() @@ -391,6 +406,9 @@ sealed interface SendRoute { @Serializable data object Address : SendRoute + @Serializable + data object ContactSelect : SendRoute + @Serializable data object Amount : SendRoute diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt index 92be39d27..02caf41a7 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt @@ -163,6 +163,20 @@ class ActivityDetailViewModel @Inject constructor( } } + fun detachContact() { + val id = activity?.rawId() ?: return + viewModelScope.launch(bgDispatcher) { + activityRepo.clearContact( + forPaymentId = id, + syncLdkPayments = false, + ).onSuccess { + reloadActivity(id) + }.onFailure { + Logger.error("Failed to detach contact for activity '$id'", it, context = TAG) + } + } + } + fun fetchTransactionDetails(txid: String) { viewModelScope.launch(bgDispatcher) { activityRepo.getTransactionDetails(txid) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 9c0237c17..ea780376b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1055,7 +1055,7 @@ class AppViewModel @Inject constructor( SendEvent.ClearPayConfirmation -> _sendUiState.update { s -> s.copy(shouldConfirmPay = false) } SendEvent.BackToAmount -> setSendEffect(SendEffect.PopBack(SendRoute.Amount)) SendEvent.NavToAddress -> setSendEffect(SendEffect.NavigateToAddress) - SendEvent.Contacts -> setSendEffect(SendEffect.NavigateToComingSoon) + SendEvent.Contacts -> setSendEffect(SendEffect.NavigateToContacts) } } } @@ -1064,6 +1064,7 @@ class AppViewModel @Inject constructor( private val isMainScanner get() = currentSheet.value !is Sheet.Send private fun onEnterManuallyClick() { + clearActiveContactPaymentContext() resetAddressInput() setSendEffect(SendEffect.NavigateToAddress) } @@ -1302,6 +1303,7 @@ class AppViewModel @Inject constructor( } private fun onAddressContinue(data: String) { + clearActiveContactPaymentContext() launchScan(source = ScanSource.ADDRESS_CONTINUE, data = data, routePubkyKeys = true) } @@ -1500,6 +1502,7 @@ class AppViewModel @Inject constructor( } private fun onPasteClick() { + clearActiveContactPaymentContext() val data = context.getClipboardText()?.trim() if (data.isNullOrBlank()) { toast( @@ -1513,6 +1516,7 @@ class AppViewModel @Inject constructor( } private fun onScanClick() { + clearActiveContactPaymentContext() setSendEffect(SendEffect.NavigateToScan) } @@ -1550,8 +1554,9 @@ class AppViewModel @Inject constructor( result: String, routePubkyKeys: Boolean, ) = withContext(bgDispatcher) { + val contactPaymentProfile = activeContactPaymentProfile() // always reset state on new scan - resetSendState() + resetSendState(contactPaymentProfile = contactPaymentProfile) resetQuickPay() val fromMainScanner = isMainScanner @@ -1653,6 +1658,13 @@ class AppViewModel @Inject constructor( activeContactPaymentContext?.publicKey } + private fun activeContactPaymentProfile(): PubkyProfile? { + val publicKey = activeContactPaymentPublicKey() ?: return null + return pubkyRepo.contacts.value.firstOrNull { + PubkyPublicKeyFormat.matches(it.publicKey, publicKey) + } ?: PubkyProfile.placeholder(publicKey) + } + @Suppress("LongMethod", "CyclomaticComplexMethod", "ReturnCount") private suspend fun onScanOnchain( invoice: OnChainInvoice, @@ -2233,7 +2245,7 @@ class AppViewModel @Inject constructor( setSendEffect(SendEffect.NavigateToPending(it.paymentHash, displayAmountSats.toLong())) return@onFailure } - if (contactPublicKey != null && PrivatePaykitRepo.isDuplicatePaymentError(it)) { + if (contactPublicKey != null) { discardContactLightningEndpoint(contactPublicKey, paymentHash) } // Delete pre-activity metadata on failure @@ -2532,7 +2544,7 @@ class AppViewModel @Inject constructor( ).getOrDefault(0u).toLong() } - suspend fun resetSendState() { + suspend fun resetSendState(contactPaymentProfile: PubkyProfile? = null) { addressValidationJob?.cancel() val speed = settingsStore.data.first().defaultTransactionSpeed val rates = let { @@ -2545,6 +2557,7 @@ class AppViewModel @Inject constructor( SendUiState( speed = speed, feeRates = rates, + contactPaymentProfile = contactPaymentProfile, ) } } @@ -3026,6 +3039,7 @@ data class SendUiState( val fees: ImmutableMap = persistentMapOf(), val estimatedRoutingFee: ULong = 0uL, val lastLightningFee: Long = 0L, + val contactPaymentProfile: PubkyProfile? = null, ) enum class SanityWarning(@StringRes val message: Int, val testTag: String) { @@ -3057,6 +3071,7 @@ sealed class SendEffect { data object NavigateToQuickPay : SendEffect() data object NavigateToFee : SendEffect() data object NavigateToFeeCustom : SendEffect() + data object NavigateToContacts : SendEffect() data object NavigateToComingSoon : SendEffect() data object PaymentSuccess : SendEffect() data class NavigateToPending(val paymentHash: String, val amount: Long) : SendEffect() diff --git a/app/src/main/res/drawable/ic_user_minus.xml b/app/src/main/res/drawable/ic_user_minus.xml new file mode 100644 index 000000000..26279550b --- /dev/null +++ b/app/src/main/res/drawable/ic_user_minus.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 74cfb418b..7edd3e402 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -821,6 +821,16 @@ General System Settings Language + Payments from contacts + *Public payments with contacts requires payment data to be shared publicly. + Choose how you prefer to receive money when users send funds to your profile key. + Keep at least one payment method enabled. + Lightning (Bitkit) + On-chain (Bitkit) + Payment options + Private payments with contacts + Public payments with contacts* + Payment Preference Bitkit QuickPay makes checking out faster by automatically paying QR codes when scanned. <accent>Frictionless</accent>\npayments QuickPay @@ -941,6 +951,8 @@ Address All Activity Assign + No contacts to assign. + Assign Contact Received Bitcoin Sent Bitcoin Boost @@ -953,7 +965,9 @@ Confirmed Confirming Confirms in {feeRateDescription} + Contact Date + Detach Failed to load activity Activity not found Explore @@ -1090,6 +1104,8 @@ Please copy an address or an invoice. Clipboard Empty Confirming in + You don\'t have any contacts yet. + Select Contact Details It appears you are sending over $100. Do you want to continue? It appears you are sending over 50% of your total balance. Do you want to continue? diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index b71330941..9f7b0eefb 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -38,10 +38,12 @@ import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.models.NodeLifecycleState import to.bitkit.models.PrivatePaykitContactLinkBackupV1 +import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.services.CoreService import to.bitkit.services.PubkyService import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError +import java.security.MessageDigest import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull @@ -108,9 +110,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(settingsStore.data).thenReturn(settingsData) whenever(lightningRepo.lightningState).thenReturn(lightningState) whenever(clock.now()).thenReturn(Instant.fromEpochSeconds(NOW_SECONDS)) + PublicPaykitRepo.lightningRouteHintsValidator = { true } whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)).thenReturn(null) whenever { keychain.delete(any()) }.thenReturn(Unit) whenever { keychain.upsertString(any(), any()) }.thenReturn(Unit) + whenever { pubkyService.publicKeyFromSecret(SECRET_KEY_HEX) }.thenReturn(OWN_KEY) whenever { addressReservationRepo.reconcileReservedIndexesWithLdk() }.thenReturn(Result.success(Unit)) whenever { addressReservationRepo.hasContactAssignment(any()) }.thenReturn(false) whenever { walletRepo.refreshReusableReceiveAddressIfReserved() }.thenReturn(Result.success(Unit)) @@ -120,6 +124,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @After fun tearDown() { + PublicPaykitRepo.lightningRouteHintsValidator = null App.currentActivity = null } @@ -158,6 +163,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { recoveryStartedAt = NOW_SECONDS - 180, mainRecoveryAttemptId = "main-attempt", responderRecoveryAttemptId = "responder-attempt", + awaitingRecoveredRemoteEndpoints = true, ), ), ).getOrThrow() @@ -173,6 +179,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(NOW_SECONDS - 180, restored.recoveryStartedAt) assertEquals("main-attempt", restored.mainRecoveryAttemptId) assertEquals("responder-attempt", restored.responderRecoveryAttemptId) + assertTrue(restored.awaitingRecoveredRemoteEndpoints) } @Test @@ -180,12 +187,204 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { cacheData.value = PrivatePaykitCacheData( cleanupPending = true, deletedContactCleanupPendingPublicKeys = setOf(CONTACT_KEY), + profileRecoveryPending = true, ) sut.restoreBackup(null).getOrThrow() assertFalse(cacheData.value.cleanupPending) assertEquals(emptySet(), cacheData.value.deletedContactCleanupPendingPublicKeys) + assertFalse(cacheData.value.profileRecoveryPending) + } + + @Test + fun `closeAndClear can mark profile recovery pending when private contact state exists`() = test { + restoreContactBackup() + + sut.closeAndClear(markProfileRecoveryPending = true).getOrThrow() + + assertTrue(cacheData.value.profileRecoveryPending) + } + + @Test + fun `closeAndClear preserves existing profile recovery marker after state was cleared`() = test { + cacheData.value = PrivatePaykitCacheData(profileRecoveryPending = true) + + sut.closeAndClear(markProfileRecoveryPending = true).getOrThrow() + + assertTrue(cacheData.value.profileRecoveryPending) + } + + @Test + fun `closeAndClear does not mark profile recovery for contacts remembered without private state`() = test { + sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() + + sut.closeAndClear(markProfileRecoveryPending = true).getOrThrow() + + assertFalse(cacheData.value.profileRecoveryPending) + } + + @Test + fun `prepareSavedContacts starts profile recovery for saved contacts`() = test { + startForegroundWithSharingEnabled() + cacheData.value = PrivatePaykitCacheData( + profileRecoveryPending = true, + contacts = mapOf( + CONTACT_KEY to PrivatePaykitContactCacheData( + remoteEndpoints = listOf( + PrivatePaykitStoredPaymentEntryData( + methodId = MethodId.P2wpkh.rawValue, + endpointData = PublicPaykitRepo.serializePayload("bcrt1qstale"), + ), + ), + lastLocalPayloadHash = LOCAL_PAYLOAD_HASH, + linkCompletedAt = NOW_SECONDS - 60, + handshakeUpdatedAt = NOW_SECONDS - 120, + ), + ), + ) + whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) + .thenReturn(secretStateJson(linkSnapshotHex = LINK_SNAPSHOT, handshakeSnapshotHex = HANDSHAKE_SNAPSHOT)) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("session") + whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) + whenever(pubkyService.fetchFileString(any())).thenAnswer { throw PrivatePaykitTestError("not found") } + stubPendingFreshHandshake() + + sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() + + assertFalse(cacheData.value.profileRecoveryPending) + verify(pubkyService, times(2)).sessionDelete("session", "/pub/paykit/v0/private") + verify(pubkyService).initiateEncryptedLink(SECRET_KEY_HEX, CONTACT_KEY) + val snapshot = sut.backupSnapshot().getOrThrow()?.get(CONTACT_KEY) + assertNotNull(snapshot) + assertEquals(NOW_SECONDS, snapshot.recoveryStartedAt) + assertNull(snapshot.linkSnapshotHex) + assertEquals(UPDATED_HANDSHAKE_SNAPSHOT, snapshot.handshakeSnapshotHex) + assertEquals(emptyMap(), snapshot.remoteEndpoints) + assertNull(snapshot.linkCompletedAt) + assertEquals(NOW_SECONDS, snapshot.handshakeUpdatedAt) + } + + @Test + fun `prepareSavedContacts accepts newer recovery marker after recent link completion`() = test { + val remoteAttemptId = "remote-attempt" + startForegroundWithSharingEnabled() + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf( + CONTACT_KEY to PrivatePaykitContactCacheData(linkCompletedAt = NOW_SECONDS - 1), + ), + ) + whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) + .thenReturn(secretStateJson()) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("session") + whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) + whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(CONTACT_KEY) + whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)).thenReturn(LINK_ID) + val remoteMarkerPath = recoveryMarkerPath(CONTACT_KEY, OWN_KEY) + val remoteMarkerJson = recoveryMarkerJson( + writerPublicKey = CONTACT_KEY, + readerPublicKey = OWN_KEY, + stage = "init", + attemptId = remoteAttemptId, + createdAt = NOW_SECONDS, + ) + whenever(pubkyService.fetchFileString(any())).thenAnswer { + val uri = it.getArgument(0) + if (uri.contains(remoteMarkerPath)) remoteMarkerJson else throw PrivatePaykitTestError("not found") + } + whenever(pubkyService.acceptEncryptedLink(SECRET_KEY_HEX, CONTACT_KEY)).thenReturn(HANDSHAKE_ID) + whenever(pubkyService.advanceHandshake(HANDSHAKE_ID)) + .thenAnswer { throw PrivatePaykitTestError("transition_transport failed isHandshake") } + whenever(pubkyService.serializeEncryptedLinkHandshake(HANDSHAKE_ID)).thenReturn(UPDATED_HANDSHAKE_SNAPSHOT) + + sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() + + verify(pubkyService).closeEncryptedLink(LINK_ID) + verify(pubkyService).acceptEncryptedLink(SECRET_KEY_HEX, CONTACT_KEY) + verify(pubkyService, never()).initiateEncryptedLink(SECRET_KEY_HEX, CONTACT_KEY) + val snapshot = sut.backupSnapshot().getOrThrow()?.get(CONTACT_KEY) + assertNotNull(snapshot) + assertEquals(remoteAttemptId, snapshot.responderRecoveryAttemptId) + assertNull(snapshot.linkSnapshotHex) + assertEquals(UPDATED_HANDSHAKE_SNAPSHOT, snapshot.handshakeSnapshotHex) + } + + @Test + fun `prepareSavedContacts keeps profile recovery pending when transport purge fails`() = test { + startForegroundWithSharingEnabled() + cacheData.value = PrivatePaykitCacheData(profileRecoveryPending = true) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("session") + whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) + whenever(pubkyService.sessionDelete("session", "/pub/paykit/v0/private")) + .thenAnswer { throw PrivatePaykitTestError("delete failed") } + whenever(pubkyService.sessionList("session", "/pub/paykit/v0/private/")) + .thenAnswer { throw PrivatePaykitTestError("list failed") } + + sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() + + assertTrue(cacheData.value.profileRecoveryPending) + verify(pubkyService, never()).initiateEncryptedLink(any(), any()) + } + + @Test + fun `refreshSavedContactEndpoints retries profile recovery purge before publishing`() = test { + startForegroundWithSharingEnabled() + cacheData.value = PrivatePaykitCacheData(profileRecoveryPending = true) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("session") + whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) + whenever(pubkyService.sessionDelete("session", "/pub/paykit/v0/private")) + .thenAnswer { throw PrivatePaykitTestError("delete failed") } + whenever(pubkyService.sessionList("session", "/pub/paykit/v0/private/")) + .thenAnswer { throw PrivatePaykitTestError("list failed") } + + sut.refreshSavedContactEndpoints(listOf(CONTACT_KEY)).getOrThrow() + + assertTrue(cacheData.value.profileRecoveryPending) + verify(pubkyService, never()).initiateEncryptedLink(any(), any()) + verify(pubkyService, never()).setPrivatePayments(any(), any()) + } + + @Test + fun `refreshKnownSavedContactEndpoints retries profile recovery purge before publishing`() = test { + sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() + startForegroundWithSharingEnabled() + cacheData.value = PrivatePaykitCacheData(profileRecoveryPending = true) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("session") + whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) + whenever(pubkyService.sessionDelete("session", "/pub/paykit/v0/private")) + .thenAnswer { throw PrivatePaykitTestError("delete failed") } + whenever(pubkyService.sessionList("session", "/pub/paykit/v0/private/")) + .thenAnswer { throw PrivatePaykitTestError("list failed") } + + sut.refreshKnownSavedContactEndpoints("test refresh").getOrThrow() + + assertTrue(cacheData.value.profileRecoveryPending) + verify(pubkyService, never()).initiateEncryptedLink(any(), any()) + verify(pubkyService, never()).setPrivatePayments(any(), any()) + } + + @Test + fun `prepareSavedContacts fails immediate profile recovery when transport purge fails`() = test { + startForegroundWithSharingEnabled() + cacheData.value = PrivatePaykitCacheData(profileRecoveryPending = true) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("session") + whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) + whenever(pubkyService.sessionDelete("session", "/pub/paykit/v0/private")) + .thenAnswer { throw PrivatePaykitTestError("delete failed") } + whenever(pubkyService.sessionList("session", "/pub/paykit/v0/private/")) + .thenAnswer { throw PrivatePaykitTestError("list failed") } + + val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) + + assertTrue(result.isFailure) + assertTrue(cacheData.value.profileRecoveryPending) + verify(pubkyService, never()).initiateEncryptedLink(any(), any()) } @Test @@ -227,6 +426,22 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(addressReservationRepo, never()).clearContactAssignment(CONTACT_KEY) } + @Test + fun `disableSharingAndPruneUnsavedContactState defers cleanup when endpoint removal fails`() = test { + restoreContactBackup() + rememberSavedContact() + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) + whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)).thenReturn(LINK_ID) + whenever(pubkyService.setPrivatePayments(eq(LINK_ID), any())) + .thenAnswer { throw PrivatePaykitTestError("network failed") } + + val result = sut.disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) + + assertTrue(result.isSuccess) + assertTrue(cacheData.value.cleanupPending) + assertNotNull(sut.backupSnapshot().getOrThrow()?.get(CONTACT_KEY)) + } + @Test fun `retryPendingEndpointRemoval tombstones deleted contact without unpublishing public endpoints`() = test { restoreContactBackup() @@ -255,7 +470,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `retryPendingEndpointRemoval clears stale sharing cleanup marker when sharing is enabled`() = test { cacheData.value = cacheData.value.copy(cleanupPending = true) - settingsData.value = SettingsData(sharesPublicPaykitEndpoints = true) + settingsData.value = SettingsData( + sharesPublicPaykitEndpoints = true, + sharesPrivatePaykitEndpoints = true, + ) sut.retryPendingEndpointRemoval(emptyList()).getOrThrow() @@ -339,6 +557,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) .thenReturn(secretStateJson()) + whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null) sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() @@ -350,6 +569,21 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(pubkyService, never()).setPrivatePayments(any(), any()) } + @Test + fun `enableSharingAndPrepareSavedContacts restores pending cleanup marker when prepare fails`() = test { + startForegroundWithSharingEnabled() + cacheData.value = PrivatePaykitCacheData(cleanupPending = true) + whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) + whenever { addressReservationRepo.reconcileReservedIndexesWithLdk() } + .thenReturn(Result.failure(PrivatePaykitTestError("reconcile failed"))) + + val result = sut.enableSharingAndPrepareSavedContacts(listOf(CONTACT_KEY)) + + assertTrue(result.isFailure) + assertTrue(cacheData.value.cleanupPending) + } + @Test fun `prepareSavedContacts clears mismatched link snapshot and starts fresh handshake`() = test { startForegroundWithSharingEnabled() @@ -424,6 +658,87 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) } + @Test + fun `prepareSavedContacts returns NoSupportedEndpoint when immediate publish has no endpoint`() = test { + startForegroundWithSharingEnabled() + settingsData.value = settingsData.value.copy(publicPaykitOnchainEnabled = false) + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf( + CONTACT_KEY to PrivatePaykitContactCacheData( + linkCompletedAt = NOW_SECONDS, + ), + ), + ) + whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) + .thenReturn(secretStateJson()) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) + whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) + whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(CONTACT_KEY) + whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)).thenReturn(LINK_ID) + whenever(pubkyService.getPrivatePayments(LINK_ID)).thenReturn(emptyList()) + whenever(lightningRepo.canReceive()).thenReturn(false) + + val error = sut.prepareSavedContacts( + publicKeys = listOf(CONTACT_KEY), + requireImmediatePublication = true, + ).exceptionOrNull() + + assertEquals(PublicPaykitError.NoSupportedEndpoint, error) + verify(pubkyService, never()).setPrivatePayments(eq(LINK_ID), any()) + } + + @Test + fun `prepareSavedContacts defers fresh link when immediate publication is requested`() = test { + startForegroundWithSharingEnabled() + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) + whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) + stubPendingFreshHandshake() + + val result = sut.prepareSavedContacts( + publicKeys = listOf(CONTACT_KEY), + requireImmediatePublication = true, + ) + + assertTrue(result.isSuccess) + verify(pubkyService, never()).setPrivatePayments(any(), any()) + } + + @Test + fun `prepareSavedContacts fails immediate publish when stale fetch defers existing endpoint update`() = test { + val retryLinkId = "retry-link-id" + startForegroundWithSharingEnabled() + settingsData.value = settingsData.value.copy(publicPaykitOnchainEnabled = false) + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf( + CONTACT_KEY to PrivatePaykitContactCacheData( + lastLocalPayloadHash = LOCAL_PAYLOAD_HASH, + linkCompletedAt = NOW_SECONDS - 60, + ), + ), + ) + whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) + .thenReturn(secretStateJson()) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) + whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) + whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(CONTACT_KEY) + whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)) + .thenReturn(LINK_ID) + .thenReturn(retryLinkId) + whenever(pubkyService.getPrivatePayments(LINK_ID)) + .thenAnswer { throw PaykitFfiException.InvalidData("bad mac while decrypting payload") } + whenever(pubkyService.getPrivatePayments(retryLinkId)) + .thenAnswer { throw PaykitFfiException.InvalidData("bad mac while decrypting payload") } + whenever(lightningRepo.canReceive()).thenReturn(false) + + val error = sut.prepareSavedContacts( + publicKeys = listOf(CONTACT_KEY), + requireImmediatePublication = true, + ).exceptionOrNull() + + assertEquals(PrivatePaykitError.PrivateUnavailable, error) + verify(pubkyService, never()).setPrivatePayments(any(), any()) + } + @Test fun `prepareSavedContacts skips publish when eligibility changes after endpoint build`() = test { startForegroundWithSharingEnabled() @@ -528,7 +843,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { prepareStaleLinkFailure( PrivatePaykitTestError( message = "service queue failed", - cause = PaykitFfiException.InvalidData("noise state counter mismatch"), + cause = PaykitFfiException.InvalidData("bad mac while decrypting payload"), ), ) @@ -589,6 +904,177 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(emptyMap(), snapshot.remoteEndpoints) } + @Test + fun `beginSavedContactPayment defers public fallback while private recovery is pending`() = test { + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf( + CONTACT_KEY to PrivatePaykitContactCacheData( + lastLocalPayloadHash = LOCAL_PAYLOAD_HASH, + linkCompletedAt = NOW_SECONDS - 60, + recoveryStartedAt = NOW_SECONDS, + ), + ), + ) + whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) + .thenReturn(secretStateJson()) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) + whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) + whenever(pubkyService.fetchFileString(any())).thenAnswer { throw PrivatePaykitTestError("not found") } + whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(CONTACT_KEY) + whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)).thenReturn(LINK_ID) + whenever(pubkyService.getPrivatePayments(LINK_ID)).thenReturn(emptyList()) + whenever(pubkyService.serializeEncryptedLink(LINK_ID)).thenReturn(UPDATED_LINK_SNAPSHOT) + whenever(publicPaykitRepo.payableEndpoints(any())).thenAnswer { it.getArgument>(0) } + whenever(publicPaykitRepo.beginPayment(CONTACT_KEY)) + .thenReturn(Result.success(PublicPaykitPaymentResult.Opened("bitcoin:public"))) + rememberSavedContact() + + val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + + assertEquals(PublicPaykitPaymentResult.NoEndpoint, result) + verify(publicPaykitRepo, never()).beginPayment(any()) + } + + @Test + fun `beginSavedContactPayment defers public fallback once after recovered link has no endpoints`() = test { + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf( + CONTACT_KEY to PrivatePaykitContactCacheData( + lastLocalPayloadHash = LOCAL_PAYLOAD_HASH, + linkCompletedAt = NOW_SECONDS, + lastCompletedRecoveryAttemptId = "attempt", + awaitingRecoveredRemoteEndpoints = true, + ), + ), + ) + whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) + .thenReturn(secretStateJson()) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) + whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) + whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(CONTACT_KEY) + whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)).thenReturn(LINK_ID) + whenever(pubkyService.getPrivatePayments(LINK_ID)).thenReturn(emptyList()) + whenever(pubkyService.serializeEncryptedLink(LINK_ID)).thenReturn(UPDATED_LINK_SNAPSHOT) + whenever(publicPaykitRepo.payableEndpoints(any())).thenAnswer { it.getArgument>(0) } + whenever(publicPaykitRepo.beginPayment(CONTACT_KEY)) + .thenReturn(Result.success(PublicPaykitPaymentResult.Opened("bitcoin:public"))) + rememberSavedContact() + + val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + + assertEquals(PublicPaykitPaymentResult.NoEndpoint, result) + assertFalse(cacheData.value.contacts[CONTACT_KEY]?.awaitingRecoveredRemoteEndpoints == true) + verify(publicPaykitRepo, never()).beginPayment(any()) + + val secondResult = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + + assertEquals(PublicPaykitPaymentResult.Opened("bitcoin:public"), secondResult) + verify(publicPaykitRepo).beginPayment(CONTACT_KEY) + } + + @Test + fun `beginSavedContactPayment retries completed recovery until private endpoints arrive`() = test { + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf( + CONTACT_KEY to PrivatePaykitContactCacheData( + lastLocalPayloadHash = LOCAL_PAYLOAD_HASH, + linkCompletedAt = NOW_SECONDS, + lastCompletedRecoveryAttemptId = "attempt", + awaitingRecoveredRemoteEndpoints = true, + ), + ), + ) + whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) + .thenReturn(secretStateJson()) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) + whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) + whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(CONTACT_KEY) + whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)).thenReturn(LINK_ID) + whenever(pubkyService.getPrivatePayments(LINK_ID)) + .thenReturn(emptyList()) + .thenReturn( + listOf( + FfiPaymentEntry( + MethodId.Bolt11.rawValue, + PublicPaykitRepo.serializePayload(PRIVATE_BOLT11), + ), + ), + ) + whenever(pubkyService.serializeEncryptedLink(LINK_ID)).thenReturn(UPDATED_LINK_SNAPSHOT) + whenever(publicPaykitRepo.payableEndpoints(any())).thenAnswer { it.getArgument>(0) } + whenever(coreService.decode(PRIVATE_BOLT11)).thenReturn( + Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(1, 2, 3))), + ) + whenever(lightningRepo.getPayments()).thenReturn(Result.success(emptyList())) + whenever(publicPaykitRepo.beginPayment(CONTACT_KEY)) + .thenReturn(Result.success(PublicPaykitPaymentResult.Opened("bitcoin:public"))) + rememberSavedContact() + + val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + + assertEquals(PublicPaykitPaymentResult.Opened(PRIVATE_BOLT11), result) + assertFalse(cacheData.value.contacts[CONTACT_KEY]?.awaitingRecoveredRemoteEndpoints == true) + verify(publicPaykitRepo, never()).beginPayment(any()) + } + + @Test + fun `beginSavedContactPayment allows public fallback after recovered endpoints are consumed`() = test { + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf( + CONTACT_KEY to PrivatePaykitContactCacheData( + lastLocalPayloadHash = LOCAL_PAYLOAD_HASH, + linkCompletedAt = NOW_SECONDS, + lastCompletedRecoveryAttemptId = "attempt", + awaitingRecoveredRemoteEndpoints = false, + ), + ), + ) + whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) + .thenReturn(secretStateJson()) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) + whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) + whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(CONTACT_KEY) + whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)).thenReturn(LINK_ID) + whenever(pubkyService.getPrivatePayments(LINK_ID)).thenReturn(emptyList()) + whenever(pubkyService.serializeEncryptedLink(LINK_ID)).thenReturn(UPDATED_LINK_SNAPSHOT) + whenever(publicPaykitRepo.payableEndpoints(any())).thenAnswer { it.getArgument>(0) } + whenever(publicPaykitRepo.beginPayment(CONTACT_KEY)) + .thenReturn(Result.success(PublicPaykitPaymentResult.Opened("bitcoin:public"))) + rememberSavedContact() + + val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + + assertEquals(PublicPaykitPaymentResult.Opened("bitcoin:public"), result) + verify(publicPaykitRepo).beginPayment(CONTACT_KEY) + } + + @Test + fun `beginSavedContactPayment falls back promptly for non recovery private unavailable`() = test { + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf( + CONTACT_KEY to PrivatePaykitContactCacheData( + lastLocalPayloadHash = LOCAL_PAYLOAD_HASH, + linkCompletedAt = NOW_SECONDS - 60, + ), + ), + ) + whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) + .thenReturn(secretStateJson()) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) + whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) + whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(CONTACT_KEY) + whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)) + .thenAnswer { throw PrivatePaykitError.PrivateUnavailable } + whenever(publicPaykitRepo.beginPayment(CONTACT_KEY)) + .thenReturn(Result.success(PublicPaykitPaymentResult.Opened("bitcoin:public"))) + rememberSavedContact() + + val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + + assertEquals(PublicPaykitPaymentResult.Opened("bitcoin:public"), result) + verify(publicPaykitRepo).beginPayment(CONTACT_KEY) + } + @Test fun `discardRemoteOnchainEndpoints removes attempted private address from cache`() = test { restoreContactBackup() @@ -620,7 +1106,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { .thenReturn(LINK_ID) .thenReturn(retryLinkId) whenever(pubkyService.getPrivatePayments(LINK_ID)) - .thenAnswer { throw PaykitFfiException.InvalidData("noise state counter mismatch") } + .thenAnswer { throw PaykitFfiException.InvalidData("bad mac while decrypting payload") } whenever(pubkyService.getPrivatePayments(retryLinkId)).thenReturn( listOf( FfiPaymentEntry( @@ -734,8 +1220,32 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(pubkyService.serializeEncryptedLinkHandshake(HANDSHAKE_ID)).thenReturn(UPDATED_HANDSHAKE_SNAPSHOT) } + private fun recoveryMarkerJson( + writerPublicKey: String, + readerPublicKey: String, + stage: String, + attemptId: String, + createdAt: Long, + ): String { + val path = recoveryMarkerPath(writerPublicKey, readerPublicKey) + return """{"version":1,"path":"$path","stage":"$stage","attemptId":"$attemptId","createdAt":$createdAt}""" + } + + private fun recoveryMarkerPath(writerPublicKey: String, readerPublicKey: String): String { + val writer = checkNotNull(PubkyPublicKeyFormat.normalized(writerPublicKey)) + val reader = checkNotNull(PubkyPublicKeyFormat.normalized(readerPublicKey)) + val material = "bitkit-private-paykit-recovery-v1|$writer|$reader" + val markerId = MessageDigest.getInstance("SHA-256") + .digest(material.encodeToByteArray()) + .joinToString(separator = "") { "%02x".format(it) } + return "/pub/paykit/v0/private-recovery/$markerId.json" + } + private fun startForegroundWithSharingEnabled() { - settingsData.value = SettingsData(sharesPublicPaykitEndpoints = true) + settingsData.value = SettingsData( + sharesPublicPaykitEndpoints = true, + sharesPrivatePaykitEndpoints = true, + ) whenever(walletRepo.walletExists()).thenReturn(true) App.currentActivity = CurrentActivity().also { it.onActivityStarted(mock()) } } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 4ee31dd65..72a880410 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -19,6 +19,8 @@ import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever +import to.bitkit.data.PrivatePaykitCacheData +import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.data.PubkyStore import to.bitkit.data.PubkyStoreData import to.bitkit.data.SettingsData @@ -57,11 +59,14 @@ class PubkyRepoTest : BaseUnitTest() { private val imageLoader = mock() private val pubkyStore = mock() private val settingsStore = mock() + private val privatePaykitCacheStore = mock() private val settingsFlow = MutableStateFlow(SettingsData()) + private val privatePaykitCacheFlow = MutableStateFlow(PrivatePaykitCacheData()) @Before fun setUp() = runBlocking { settingsFlow.value = SettingsData() + privatePaykitCacheFlow.value = PrivatePaykitCacheData() whenever(pubkyStore.data).thenReturn(flowOf(PubkyStoreData())) whenever(settingsStore.data).thenReturn(settingsFlow) whenever { settingsStore.update(any()) }.thenAnswer { @@ -69,6 +74,12 @@ class PubkyRepoTest : BaseUnitTest() { settingsFlow.value = transform(settingsFlow.value) Unit } + whenever(privatePaykitCacheStore.data).thenReturn(privatePaykitCacheFlow) + whenever { privatePaykitCacheStore.update(any()) }.thenAnswer { + val transform = it.getArgument<(PrivatePaykitCacheData) -> PrivatePaykitCacheData>(0) + privatePaykitCacheFlow.value = transform(privatePaykitCacheFlow.value) + Unit + } sut = createSut() } @@ -79,6 +90,7 @@ class PubkyRepoTest : BaseUnitTest() { imageLoader = imageLoader, pubkyStore = pubkyStore, settingsStore = settingsStore, + privatePaykitCacheStore = privatePaykitCacheStore, httpClient = mock(), ) @@ -154,6 +166,26 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(keychain) { delete(Keychain.Key.PUBKY_SECRET_KEY.name) } } + @Test + fun `completeAuthentication should clear profile recovery marker`() = test { + val testSecret = "session_secret" + val testPk = VALID_SELF_KEY.removePrefix("pubky") + privatePaykitCacheFlow.value = PrivatePaykitCacheData(profileRecoveryPending = true) + whenever(pubkyService.startAuth()).thenReturn("auth_uri") + whenever(pubkyService.completeAuth()).thenReturn(testSecret) + whenever(pubkyService.importSession(testSecret)).thenReturn(testPk) + val ffiProfile = createFfiProfile(name = "User") + whenever(pubkyService.getProfile(VALID_SELF_KEY)).thenReturn(ffiProfile) + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(testSecret) + whenever(pubkyService.sessionList(testSecret, Env.contactsBasePath)).thenReturn(emptyList()) + + sut.startAuthentication() + val result = sut.completeAuthentication() + + assertTrue(result.isSuccess) + assertFalse(privatePaykitCacheFlow.value.profileRecoveryPending) + } + @Test fun `completeAuthentication should load contacts automatically`() = test { val testSecret = "session_secret" diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index 0e773415d..f71e9ba47 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -5,6 +5,7 @@ import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner import com.synonym.paykit.FfiPaymentEntry import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.After import org.junit.Before import org.junit.Test import org.lightningdevkit.ldknode.Network @@ -65,6 +66,12 @@ class PublicPaykitRepoTest : BaseUnitTest() { settingsFlow.value = transform(settingsFlow.value) Unit } + PublicPaykitRepo.lightningRouteHintsValidator = { true } + } + + @After + fun tearDown() { + PublicPaykitRepo.lightningRouteHintsValidator = null } @Test @@ -154,6 +161,18 @@ class PublicPaykitRepoTest : BaseUnitTest() { verify(pubkyRepo).setPaymentEndpoint(MethodId.Bolt11.rawValue, """{"value":"lnbc1cached"}""") } + @Test + fun `syncCurrentPublishedEndpoints returns NoSupportedEndpoint when endpoint is required`() = test { + setSettings(SettingsData(publicPaykitOnchainEnabled = false)) + whenever(lightningRepo.canReceive()).thenReturn(false) + + val error = sut.syncCurrentPublishedEndpoints(requireEndpoint = true).exceptionOrNull() + + assertEquals(PublicPaykitError.NoSupportedEndpoint, error) + verify(pubkyRepo, never()).setPaymentEndpoint(any(), any()) + verify(pubkyRepo, never()).removePaymentEndpoint(any()) + } + @Test fun `refreshPublishedBolt11ForPayment rotates paid public bolt11`() = test { setSettings( diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt index 57cee1316..eb42e24f8 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Test +import org.mockito.Mockito.clearInvocations import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.verify @@ -56,6 +57,7 @@ class EditProfileViewModelTest : BaseUnitTest() { } assertFalse(sut.uiState.value.showDeleteFailureDialog) verify(pubkyRepo).deleteProfileWithSessionRetry() + verify(privatePaykitRepo).closeAndClear(markProfileRecoveryPending = true) } @Test @@ -73,6 +75,7 @@ class EditProfileViewModelTest : BaseUnitTest() { } assertFalse(sut.uiState.value.showDeleteFailureDialog) verify(pubkyRepo).deleteProfileWithSessionRetry() + verify(privatePaykitRepo).closeAndClear(markProfileRecoveryPending = true) } @Test @@ -102,6 +105,7 @@ class EditProfileViewModelTest : BaseUnitTest() { advanceUntilIdle() sut.deleteProfile() advanceUntilIdle() + clearInvocations(privatePaykitRepo) sut.effects.test { sut.disconnectProfile() @@ -111,6 +115,7 @@ class EditProfileViewModelTest : BaseUnitTest() { } assertFalse(sut.uiState.value.showDeleteFailureDialog) verify(pubkyRepo).signOut() + verify(privatePaykitRepo).closeAndClear(markProfileRecoveryPending = true) } @Test @@ -135,7 +140,7 @@ class EditProfileViewModelTest : BaseUnitTest() { whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow(TEST_PUBLIC_KEY)) whenever { privatePaykitRepo.removePublishedEndpointsBestEffort(any()) } .thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.closeAndClear() }.thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.closeAndClear(any()) }.thenReturn(Result.success(Unit)) return EditProfileViewModel( context = context, diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt index eba7c5dd1..510a2b744 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt @@ -48,6 +48,7 @@ class PayContactsViewModelTest : BaseUnitTest() { whenever(context.getString(any())).thenReturn("") whenever(settingsStore.data).thenReturn(settingsFlow) whenever(pubkyRepo.contacts).thenReturn(contactsFlow) + whenever { pubkyRepo.hasSecretKey() }.thenReturn(true) whenever { settingsStore.update(any()) }.thenAnswer { val transform = it.getArgument<(SettingsData) -> SettingsData>(0) settingsFlow.value = transform(settingsFlow.value) @@ -55,7 +56,7 @@ class PayContactsViewModelTest : BaseUnitTest() { } whenever { publicPaykitRepo.syncPublishedEndpoints(any()) }.thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.setContactSharingCleanupPending(any()) }.thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.prepareSavedContacts(any>()) } + whenever { privatePaykitRepo.prepareSavedContacts(any>(), any()) } .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } .thenReturn(Result.success(Unit)) @@ -76,12 +77,35 @@ class PayContactsViewModelTest : BaseUnitTest() { assertTrue(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints) + assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) verify(privatePaykitRepo).setContactSharingCleanupPending(false) - verify(privatePaykitRepo).prepareSavedContacts(listOf(CONTACT_KEY)) + verify(privatePaykitRepo).prepareSavedContacts(listOf(CONTACT_KEY), false) verify(privatePaykitRepo, never()).disableSharingAndPruneUnsavedContactState(any>()) } + @Test + fun `continueToProfile enables only public sharing without local secret key`() = test { + whenever { pubkyRepo.hasSecretKey() }.thenReturn(false) + val sut = createSut() + advanceUntilIdle() + + sut.effects.test { + sut.setPaymentSharingEnabled(true) + sut.continueToProfile() + advanceUntilIdle() + + assertEquals(PayContactsEffect.Continue, awaitItem()) + } + + assertTrue(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) + assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints) + assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) + verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) + verify(privatePaykitRepo, never()).setContactSharingCleanupPending(false) + verify(privatePaykitRepo, never()).prepareSavedContacts(any>(), any()) + } + @Test fun `continueToProfile keeps sharing disabled when cleanup marker clear fails`() = test { whenever { privatePaykitRepo.setContactSharingCleanupPending(false) } @@ -102,12 +126,12 @@ class PayContactsViewModelTest : BaseUnitTest() { assertFalse(sut.uiState.value.isLoading) verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) - verify(privatePaykitRepo, never()).prepareSavedContacts(any>()) + verify(privatePaykitRepo, never()).prepareSavedContacts(any>(), any()) } @Test fun `continueToProfile proceeds when private contact preparation fails`() = test { - whenever { privatePaykitRepo.prepareSavedContacts(any>()) } + whenever { privatePaykitRepo.prepareSavedContacts(any>(), any()) } .thenReturn(Result.failure(PayContactsTestAppError("private setup failed"))) val sut = createSut() advanceUntilIdle() @@ -123,7 +147,7 @@ class PayContactsViewModelTest : BaseUnitTest() { assertTrue(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints) verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) - verify(privatePaykitRepo).prepareSavedContacts(listOf(CONTACT_KEY)) + verify(privatePaykitRepo).prepareSavedContacts(listOf(CONTACT_KEY), false) } @Test @@ -177,6 +201,36 @@ class PayContactsViewModelTest : BaseUnitTest() { verify(privatePaykitRepo).setContactSharingCleanupPending(true) } + @Test + fun `continueToProfile restores public sharing when public cleanup fails`() = test { + settingsFlow.value = SettingsData( + hasConfirmedPublicPaykitEndpoints = true, + sharesPublicPaykitEndpoints = true, + sharesPrivatePaykitEndpoints = true, + ) + whenever { publicPaykitRepo.syncPublishedEndpoints(publish = false) } + .thenReturn(Result.failure(PayContactsTestAppError("public cleanup failed"))) + val sut = createSut() + advanceUntilIdle() + + sut.effects.test { + sut.setPaymentSharingEnabled(false) + sut.continueToProfile() + advanceUntilIdle() + + expectNoEvents() + } + + assertTrue(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) + assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints) + assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) + assertFalse(sut.uiState.value.isLoading) + assertTrue(sut.uiState.value.isPaymentSharingEnabled) + verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) + verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) + verify(privatePaykitRepo, never()).setContactSharingCleanupPending(true) + } + private fun createSut() = PayContactsViewModel( context = context, settingsStore = settingsStore, diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt new file mode 100644 index 000000000..c1334adfa --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt @@ -0,0 +1,56 @@ +package to.bitkit.ui.screens.profile + +import android.content.Context +import app.cash.turbine.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.repositories.PrivatePaykitRepo +import to.bitkit.repositories.PubkyRepo +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class ProfileViewModelTest : BaseUnitTest() { + private val context: Context = mock() + private val pubkyRepo: PubkyRepo = mock() + private val privatePaykitRepo: PrivatePaykitRepo = mock() + + @Test + fun `signOut marks profile recovery before signing out`() = test { + whenever(pubkyRepo.signOut()).thenReturn(Result.success(Unit)) + val sut = createSut() + advanceUntilIdle() + + sut.effects.test { + sut.signOut() + advanceUntilIdle() + + assertEquals(ProfileEffect.SignedOut, awaitItem()) + } + verify(privatePaykitRepo).closeAndClear(markProfileRecoveryPending = true) + verify(pubkyRepo).signOut() + } + + private fun createSut(): ProfileViewModel { + whenever(context.getString(any())).thenReturn("") + whenever(pubkyRepo.profile).thenReturn(MutableStateFlow(null)) + whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow("pubkyalice")) + whenever(pubkyRepo.isLoadingProfile).thenReturn(MutableStateFlow(false)) + whenever { pubkyRepo.loadProfile() }.thenReturn(Unit) + whenever { privatePaykitRepo.removePublishedEndpointsBestEffort(any()) } + .thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.closeAndClear(any()) }.thenReturn(Result.success(Unit)) + + return ProfileViewModel( + context = context, + pubkyRepo = pubkyRepo, + privatePaykitRepo = privatePaykitRepo, + ) + } +} diff --git a/app/src/test/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModelTest.kt b/app/src/test/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModelTest.kt new file mode 100644 index 000000000..132acf438 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModelTest.kt @@ -0,0 +1,245 @@ +package to.bitkit.ui.settings.paymentPreference + +import android.content.Context +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.models.PubkyProfile +import to.bitkit.repositories.PrivatePaykitRepo +import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.PublicPaykitError +import to.bitkit.repositories.PublicPaykitRepo +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class PaymentPreferenceViewModelTest : BaseUnitTest() { + companion object { + private const val CONTACT_KEY = "pubkycytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo" + } + + private val context: Context = mock() + private val settingsStore: SettingsStore = mock() + private val publicPaykitRepo: PublicPaykitRepo = mock() + private val privatePaykitRepo: PrivatePaykitRepo = mock() + private val pubkyRepo: PubkyRepo = mock() + + private val settingsFlow = MutableStateFlow(SettingsData()) + private val contactsFlow = MutableStateFlow(listOf(createPaymentPreferenceContact(CONTACT_KEY))) + private val isAuthenticatedFlow = MutableStateFlow(true) + + @Before + fun setUp() { + settingsFlow.value = SettingsData() + contactsFlow.value = listOf(createPaymentPreferenceContact(CONTACT_KEY)) + isAuthenticatedFlow.value = true + + whenever(context.getString(any())).thenReturn("") + whenever(settingsStore.data).thenReturn(settingsFlow) + whenever(pubkyRepo.contacts).thenReturn(contactsFlow) + whenever(pubkyRepo.isAuthenticated).thenReturn(isAuthenticatedFlow) + whenever { pubkyRepo.hasSecretKey() }.thenReturn(true) + whenever { settingsStore.update(any()) }.thenAnswer { + val transform = it.getArgument<(SettingsData) -> SettingsData>(0) + settingsFlow.value = transform(settingsFlow.value) + Unit + } + whenever { publicPaykitRepo.syncCurrentPublishedEndpoints(any(), any()) } + .thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.setContactSharingCleanupPending(any()) } + .thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.enableSharingAndPrepareSavedContacts(any>()) } + .thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.prepareSavedContacts(any>(), any()) } + .thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } + .thenReturn(Result.success(Unit)) + } + + @Test + fun `setPrivateContactsEnabled prepares contacts without immediate publication`() = test { + val sut = createSut() + advanceUntilIdle() + + sut.setPrivateContactsEnabled(true) + advanceUntilIdle() + + assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) + verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(listOf(CONTACT_KEY)) + } + + @Test + fun `setPrivateContactsEnabled does not enable without profile`() = test { + isAuthenticatedFlow.value = false + val sut = createSut() + advanceUntilIdle() + + sut.setPrivateContactsEnabled(true) + advanceUntilIdle() + + assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) + assertFalse(sut.uiState.value.hasPubkyProfile) + verify(settingsStore, never()).update(any()) + verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>()) + } + + @Test + fun `setPrivateContactsEnabled does not enable without local secret key`() = test { + whenever { pubkyRepo.hasSecretKey() }.thenReturn(false) + val sut = createSut() + advanceUntilIdle() + + sut.setPrivateContactsEnabled(true) + advanceUntilIdle() + + assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) + assertFalse(sut.uiState.value.canUsePrivateContacts) + verify(privatePaykitRepo, never()).enableSharingAndPrepareSavedContacts(any>()) + } + + @Test + fun `init clears hidden private sharing when local secret key is unavailable`() = test { + settingsFlow.value = SettingsData(sharesPrivatePaykitEndpoints = true) + whenever { pubkyRepo.hasSecretKey() }.thenReturn(false) + val sut = createSut() + advanceUntilIdle() + + assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) + assertFalse(sut.uiState.value.privateContactsEnabled) + assertFalse(sut.uiState.value.canUsePrivateContacts) + } + + @Test + fun `setPrivateContactsEnabled rolls back when private enable fails`() = test { + whenever { privatePaykitRepo.enableSharingAndPrepareSavedContacts(any>()) } + .thenReturn(Result.failure(PublicPaykitError.WalletNotReady)) + val sut = createSut() + advanceUntilIdle() + + sut.setPrivateContactsEnabled(true) + advanceUntilIdle() + + assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) + verify(privatePaykitRepo).enableSharingAndPrepareSavedContacts(listOf(CONTACT_KEY)) + } + + @Test + fun `setOnchainEnabled rolls back when public sync has no supported endpoint`() = test { + settingsFlow.value = SettingsData( + sharesPublicPaykitEndpoints = true, + publicPaykitLightningEnabled = true, + publicPaykitOnchainEnabled = true, + ) + whenever { + publicPaykitRepo.syncCurrentPublishedEndpoints( + forceRefreshLightning = true, + requireEndpoint = true, + ) + }.thenReturn(Result.failure(PublicPaykitError.NoSupportedEndpoint)) + val sut = createSut() + advanceUntilIdle() + + sut.setOnchainEnabled(false) + advanceUntilIdle() + + assertTrue(settingsFlow.value.publicPaykitOnchainEnabled) + assertTrue(settingsFlow.value.publicPaykitLightningEnabled) + assertFalse(sut.uiState.value.isUpdatingPaymentOptions) + verify(publicPaykitRepo, times(2)).syncCurrentPublishedEndpoints( + forceRefreshLightning = true, + requireEndpoint = true, + ) + } + + @Test + fun `setOnchainEnabled rollback preserves concurrent sharing changes`() = test { + settingsFlow.value = SettingsData( + sharesPublicPaykitEndpoints = true, + sharesPrivatePaykitEndpoints = false, + publicPaykitLightningEnabled = true, + publicPaykitOnchainEnabled = true, + ) + var updateCount = 0 + whenever { settingsStore.update(any()) }.thenAnswer { + updateCount += 1 + val transform = it.getArgument<(SettingsData) -> SettingsData>(0) + if (updateCount == 2) { + settingsFlow.value = settingsFlow.value.copy( + sharesPublicPaykitEndpoints = false, + sharesPrivatePaykitEndpoints = true, + ) + } + settingsFlow.value = transform(settingsFlow.value) + Unit + } + whenever { + publicPaykitRepo.syncCurrentPublishedEndpoints(true, true) + }.thenReturn(Result.failure(PublicPaykitError.NoSupportedEndpoint)) + val sut = createSut() + advanceUntilIdle() + + sut.setOnchainEnabled(false) + advanceUntilIdle() + + assertTrue(settingsFlow.value.publicPaykitOnchainEnabled) + assertTrue(settingsFlow.value.publicPaykitLightningEnabled) + assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) + assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) + assertFalse(sut.uiState.value.isUpdatingPaymentOptions) + verify(publicPaykitRepo).syncCurrentPublishedEndpoints(true, true) + verify(privatePaykitRepo).prepareSavedContacts(listOf(CONTACT_KEY), true) + } + + @Test + fun `setOnchainEnabled rolls back when private immediate publication fails`() = test { + settingsFlow.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = true, + publicPaykitOnchainEnabled = true, + ) + whenever { privatePaykitRepo.prepareSavedContacts(any>(), eq(true)) } + .thenReturn(Result.failure(PublicPaykitError.WalletNotReady)) + val sut = createSut() + advanceUntilIdle() + + sut.setOnchainEnabled(false) + advanceUntilIdle() + + assertTrue(settingsFlow.value.publicPaykitOnchainEnabled) + assertTrue(settingsFlow.value.publicPaykitLightningEnabled) + assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) + assertFalse(sut.uiState.value.isUpdatingPaymentOptions) + verify(privatePaykitRepo, times(2)).prepareSavedContacts(listOf(CONTACT_KEY), true) + } + + private fun createSut() = PaymentPreferenceViewModel( + context = context, + settingsStore = settingsStore, + publicPaykitRepo = publicPaykitRepo, + privatePaykitRepo = privatePaykitRepo, + pubkyRepo = pubkyRepo, + ) +} + +private fun createPaymentPreferenceContact(publicKey: String) = PubkyProfile( + publicKey = publicKey, + name = "Alice", + bio = "", + imageUrl = null, + links = emptyList(), + tags = persistentListOf(), + status = null, +) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index d2a285f31..94ef160bb 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -143,7 +143,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(pubkyRepo.publicKey).thenReturn(pubkyPublicKey) whenever(pubkyRepo.contacts).thenReturn(pubkyContacts) whenever(pubkyRepo.contactsLoadVersion).thenReturn(pubkyContactsLoadVersion) - whenever { privatePaykitRepo.prepareSavedContacts(any>()) } + whenever { privatePaykitRepo.prepareSavedContacts(any>(), any()) } .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.pruneUnsavedContactState(any>()) } .thenReturn(Result.success(Unit)) @@ -502,6 +502,29 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) } + @Test + fun `manual send path clears stale contact context`() = test { + setActiveContactPaymentContext("pubkycontact") + + sut.setSendEvent(SendEvent.EnterManually) + advanceUntilIdle() + + assertNull(activeContactPaymentContext()) + } + + @Test + fun `address continue clears stale contact context before decoding`() = test { + val bolt11 = "lnbcrt1manual" + setActiveContactPaymentContext("pubkycontact") + stubLightningScan(bolt11 = bolt11, amountSats = 0u) + + sut.setSendEvent(SendEvent.AddressContinue(bolt11)) + advanceUntilIdle() + + assertNull(activeContactPaymentContext()) + assertNull(sut.sendUiState.value.contactPaymentProfile) + } + @Test fun `private onchain contact payment discards remote address after send`() = test { val address = "bcrt1qprivatecontact" @@ -768,13 +791,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pubkyPublicKey.value = testPublicKey advanceUntilIdle() - verify(privatePaykitRepo, never()).prepareSavedContacts(any>()) + verify(privatePaykitRepo, never()).prepareSavedContacts(any>(), any()) verify(privatePaykitRepo, never()).pruneUnsavedContactState(any>()) pubkyContactsLoadVersion.value = 1L advanceUntilIdle() - verify(privatePaykitRepo).prepareSavedContacts(any>()) + verify(privatePaykitRepo).prepareSavedContacts(any>(), any()) verify(privatePaykitRepo).pruneUnsavedContactState(any>()) } @@ -799,7 +822,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() verify(privatePaykitRepo).removeSavedContact(contact.publicKey) - verify(privatePaykitRepo).prepareSavedContacts(emptySet()) + verify(privatePaykitRepo).prepareSavedContacts(emptySet(), false) verify(privatePaykitRepo).pruneUnsavedContactState(emptySet()) } @@ -835,7 +858,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private suspend fun enablePublicPaykitSharing() { settingsData.value = SettingsData(sharesPublicPaykitEndpoints = true) walletState.value = WalletState(onchainAddress = "bc1qtest") - whenever { publicPaykitRepo.syncCurrentPublishedEndpoints(any()) }.thenReturn(Result.success(Unit)) + whenever { publicPaykitRepo.syncCurrentPublishedEndpoints(any(), any()) }.thenReturn(Result.success(Unit)) } @Suppress("UNCHECKED_CAST") diff --git a/changelog.d/next/945.added.md b/changelog.d/next/945.added.md new file mode 100644 index 000000000..77a2c26eb --- /dev/null +++ b/changelog.d/next/945.added.md @@ -0,0 +1 @@ +Added contact payment flows, activity contact attribution, and payment preference controls for private payments. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 394418546..00ebbfdd0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.58" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc5" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc6" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" }