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/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/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index aa9ab9659..148b08a59 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -100,15 +100,37 @@ 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 addressReservationRepo.reconcileReservedIndexesWithLdk().getOrThrow() - publishLocalEndpoints(keys, maxAdvanceSteps = 3, reason = "prepare").getOrThrow() + publishLocalEndpoints( + publicKeys = keys, + maxAdvanceSteps = 3, + reason = "prepare", + requireImmediatePublication = requireImmediatePublication, + ).getOrThrow() } } + suspend fun enableSharingAndPrepareSavedContacts(publicKeys: Collection): Result = + withContext(serializedDispatcher) { + runCatching { + val wasCleanupPending = isContactSharingCleanupPending() + updateContactSharingCleanupPending(false) + prepareSavedContacts(publicKeys).onFailure { + if (wasCleanupPending) { + runCatching { updateContactSharingCleanupPending(true) } + .onFailure(it::addSuppressed) + } + }.getOrThrow() + } + } + suspend fun refreshSavedContactEndpoints(publicKeys: Collection): Result = withContext(serializedDispatcher) { runCatching { @@ -140,14 +162,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 { @@ -507,7 +524,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 +532,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 +546,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 +565,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,6 +591,7 @@ class PrivatePaykitRepo @Inject constructor( it, context = TAG, ) + if (firstError == null && requireImmediatePublication) firstError = it } val updatedContactState = ensureState().contacts[normalizedKey] val needsRetry = publishResult.isFailure || @@ -570,6 +604,8 @@ class PrivatePaykitRepo @Inject constructor( cancelPendingPublicationRetry(normalizedKey) } } + firstError?.let { throw it } + Unit } } @@ -751,10 +787,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 +820,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 +840,12 @@ class PrivatePaykitRepo @Inject constructor( rawPayload = PublicPaykitRepo.serializePayload(invoice.bolt11), ) }.onFailure { - ensureState().contacts[publicKey]?.localInvoice = null - persistState() Logger.warn( "Failed to prepare private Paykit invoice for '${redacted(publicKey)}'", it, context = TAG, ) } - } else { - ensureState().contacts[publicKey]?.localInvoice = null - persistState() } endpoints @@ -1565,7 +1599,7 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun canPublishPrivateEndpoints(): Boolean { val settings = settingsStore.data.first() - return settings.sharesPublicPaykitEndpoints && + return settings.sharesPrivatePaykitEndpoints && App.currentActivity?.value != null && walletRepo.walletExists() && lightningRepo.lightningState.value.nodeLifecycleState.isRunning() diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index 75ce1fb42..fe6dacd96 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -79,6 +79,12 @@ class PublicPaykitRepo @Inject constructor( private val publicBolt11Expiry = 24.hours private val publicBolt11RefreshWindow = 30.minutes + 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 @@ -171,11 +177,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 +255,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 +268,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 +290,7 @@ class PublicPaykitRepo @Inject constructor( ) } - if (endpoints.isEmpty()) throw PublicPaykitError.NoSupportedEndpoint + if (endpoints.isEmpty() && requireEndpoint) throw PublicPaykitError.NoSupportedEndpoint return endpoints } diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 1d0b09833..2522fbd32 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -174,6 +174,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 +1186,11 @@ private fun NavGraphBuilder.generalSettingsSubScreens(navController: NavHostCont onBack = { navController.popBackStack() }, ) } + composableWithDefaultTransitions { + PaymentPreferenceScreen( + onBack = { navController.popBackStack() }, + ) + } composableWithDefaultTransitions { BackgroundPaymentsIntroScreen( @@ -1715,6 +1721,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 +1756,9 @@ sealed interface Routes { @Serializable data object TransactionSpeedSettings : Routes + @Serializable + data object PaymentPreferenceSettings : Routes + @Serializable data object WidgetsSettings : 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/PayContactsViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt index 248c03403..b76a5e5fe 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 @@ -104,6 +104,7 @@ class PayContactsViewModel @Inject constructor( it.copy( hasConfirmedPublicPaykitEndpoints = true, sharesPublicPaykitEndpoints = true, + sharesPrivatePaykitEndpoints = true, ) } }.onFailure { @@ -116,32 +117,49 @@ class PayContactsViewModel @Inject constructor( } 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) } @@ -160,7 +178,9 @@ class PayContactsViewModel @Inject constructor( } private fun resolvedSharingDefault(settings: SettingsData): Boolean = - settings.sharesPublicPaykitEndpoints || !settings.hasConfirmedPublicPaykitEndpoints + settings.sharesPublicPaykitEndpoints || + settings.sharesPrivatePaykitEndpoints || + !settings.hasConfirmedPublicPaykitEndpoints } @Immutable 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..d37a9b6b0 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 @@ -15,9 +15,12 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.runtime.Composable @@ -30,6 +33,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 +41,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 +58,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,21 +69,28 @@ 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 import to.bitkit.ui.blocktankViewModel import to.bitkit.ui.components.BalanceHeaderView +import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.BottomSheet import to.bitkit.ui.components.BottomSheetPreview 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.SheetSize import to.bitkit.ui.components.TagButton import to.bitkit.ui.components.Title import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon +import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.screens.wallets.activity.components.ActivityAddTagSheet import to.bitkit.ui.screens.wallets.activity.components.ActivityIcon import to.bitkit.ui.settingsViewModel @@ -85,8 +98,9 @@ import to.bitkit.ui.shared.UiConstants 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.shared.util.gradientBackground 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 @@ -177,6 +191,7 @@ 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) } @@ -205,6 +220,7 @@ fun ActivityDetailScreen( } val context = LocalContext.current + val assignedContact = assignedContactProfile(item, contacts) val blocktankInfo by blocktankViewModel?.info?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(null) } @@ -226,10 +242,12 @@ fun ActivityDetailScreen( ) ActivityDetailContent( item = item, + assignedContact = assignedContact, tags = tags, onRemoveTag = { detailViewModel.removeTag(it) }, onAddTagClick = { showAddTagSheet = true }, onAssignClick = { showAssignSheet = true }, + onDetachClick = { detailViewModel.detachContact() }, onClickBoost = detailViewModel::onClickBoost, onExploreClick = onExploreClick, onChannelClick = onChannelClick, @@ -298,9 +316,13 @@ fun ActivityDetailScreen( } if (showAssignSheet) { - ComingSoonSheet( - onWalletOverviewClick = onCloseClick, - onBack = { showAssignSheet = false }, + AssignActivityContactSheet( + contacts = contacts, + onDismiss = { showAssignSheet = false }, + onClickContact = { + detailViewModel.assignContact(it.publicKey) + showAssignSheet = false + }, ) } } @@ -312,10 +334,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 +418,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 +560,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 +615,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 +744,188 @@ 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() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AssignActivityContactSheet( + contacts: ImmutableList, + onDismiss: () -> Unit, + onClickContact: (PubkyProfile) -> Unit, +) { + BottomSheet(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .fillMaxSize() + .sheetHeight(SheetSize.MEDIUM, isModal = true) + .gradientBackground() + .testTag("AssignActivityContactSheet") + ) { + SheetTopBar( + titleText = stringResource(R.string.wallet__activity_assign_contact_title), + onBack = onDismiss, + ) + if (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, + ) + } + return@Column + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + items(contacts, key = { it.publicKey }) { contact -> + AssignActivityContactRow( + contact = contact, + onClickContact = { onClickContact(contact) }, + modifier = Modifier + .testTag("AssignActivityContact_${contact.publicKey}") + ) + HorizontalDivider(color = Colors.White10) + } + } + } + } +} + +@Composable +private fun AssignActivityContactRow( + contact: PubkyProfile, + onClickContact: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier + .fillMaxWidth() + .clickableAlpha { onClickContact() } + .padding(vertical = 12.dp) + ) { + PubkyContactAvatar(profile = contact) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.weight(1f) + ) { + BodyM( + text = contact.truncatedPublicKey, + color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + BodySSB( + text = contact.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + @Composable private fun StatusSection( item: Activity, @@ -889,10 +1082,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 +1115,12 @@ private fun PreviewOnchain() { confirmTimestamp = (System.currentTimeMillis() / 1000).toULong(), ) ), + assignedContact = null, tags = persistentListOf(), onRemoveTag = {}, onAddTagClick = {}, onAssignClick = {}, + onDetachClick = {}, onExploreClick = {}, onChannelClick = null, onCopy = {}, @@ -952,10 +1149,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..cbb1f3aee 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 @@ -150,6 +151,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 +323,12 @@ private fun GeneralTabContent( onClick = { onEvent(SettingsEvent.TransactionSpeedClick) }, modifier = Modifier.testTag("TransactionSpeedSettings") ) + 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 +626,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 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..01d5bfb50 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceScreen.kt @@ -0,0 +1,137 @@ +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") + ) + + SectionHeader( + title = stringResource(R.string.settings__payment_pref_contacts), + padding = PaddingValues(top = 16.dp), + ) + + 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) + 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, + ), + ) + } +} 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..d230fbca6 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModel.kt @@ -0,0 +1,199 @@ +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.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 { + settingsStore.data.collect { settings -> + _uiState.update { + it.copy( + lightningEnabled = settings.publicPaykitLightningEnabled, + onchainEnabled = settings.publicPaykitOnchainEnabled, + privateContactsEnabled = settings.sharesPrivatePaykitEndpoints, + publicContactsEnabled = settings.sharesPublicPaykitEndpoints, + ) + } + } + } + } + + fun setLightningEnabled(isEnabled: Boolean) { + updatePaymentMethod(lightningEnabled = isEnabled) + } + + fun setOnchainEnabled(isEnabled: Boolean) { + updatePaymentMethod(onchainEnabled = isEnabled) + } + + fun setPrivateContactsEnabled(isEnabled: Boolean) { + if (_uiState.value.isUpdatingPrivateContacts) 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 + 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) { + privatePaykitRepo.prepareSavedContacts(contactPublicKeys(), requireImmediatePublication = true).getOrThrow() + } + } + + 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__pay_contacts_error_session) + 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 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..2d4d0e124 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt @@ -163,6 +163,35 @@ class ActivityDetailViewModel @Inject constructor( } } + fun assignContact(publicKey: String) { + val id = activity?.rawId() ?: return + viewModelScope.launch(bgDispatcher) { + activityRepo.setContact( + contactPublicKey = publicKey, + forPaymentId = id, + syncLdkPayments = false, + ).onSuccess { + reloadActivity(id) + }.onFailure { + Logger.error("Failed to assign contact for activity '$id'", it, context = TAG) + } + } + } + + 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..14af34c60 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, @@ -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..4717996da 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 + Pay to/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..1d6b36a7a 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -255,7 +255,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() @@ -350,6 +353,19 @@ 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 { 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 +440,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("noise state counter mismatch") } + whenever(pubkyService.getPrivatePayments(retryLinkId)) + .thenAnswer { throw PaykitFfiException.InvalidData("noise state counter mismatch") } + 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() @@ -735,7 +832,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } 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/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index 0e773415d..60843674c 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -154,6 +154,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/PayContactsViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt index eba7c5dd1..57d17d9ef 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 @@ -55,7 +55,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)) @@ -78,7 +78,7 @@ class PayContactsViewModelTest : BaseUnitTest() { assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints) 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>()) } @@ -102,12 +102,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 +123,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 +177,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/settings/paymentPreference/PaymentPreferenceViewModelTest.kt b/app/src/test/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModelTest.kt new file mode 100644 index 000000000..f82d24704 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModelTest.kt @@ -0,0 +1,199 @@ +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.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))) + + @Before + fun setUp() { + settingsFlow.value = SettingsData() + contactsFlow.value = listOf(createPaymentPreferenceContact(CONTACT_KEY)) + + whenever(context.getString(any())).thenReturn("") + whenever(settingsStore.data).thenReturn(settingsFlow) + whenever(pubkyRepo.contacts).thenReturn(contactsFlow) + 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 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.