diff --git a/.claude/commands/pr.md b/.claude/commands/pr.md index 2a5505ef9..8bc662c94 100644 --- a/.claude/commands/pr.md +++ b/.claude/commands/pr.md @@ -1,5 +1,5 @@ --- -description: "Create a PR on GitHub, e.g. /pr --draft -- focus on the new wallet sync logic" +description: "/pr [base] [--dry] [--draft] [-- instructions] — Create a PR on GitHub" argument_hint: "[branch] [--dry] [--draft] [-- instructions]" allowed_tools: Bash, Read, Glob, Grep, Write, AskUserQuestion, mcp__github__create_pull_request, mcp__github__list_pull_requests, mcp__github__get_file_contents, mcp__github__issue_read --- diff --git a/AGENTS.md b/AGENTS.md index b3f1bcda3..1600ddb18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,7 @@ E2E=true E2E_BACKEND=network ./gradlew assembleTnetRelease - **State Management**: StateFlow, SharedFlow - **Navigation**: Compose Navigation with strongly typed routes - **Push Notifications**: Firebase -- **Storage**: DataStore with json files +- **Storage**: DataStore with JSON files ### Project Structure @@ -165,10 +165,12 @@ suspend fun getData(): Result = withContext(Dispatchers.IO) { - USE single-line commit messages under 50 chars; use conventional commit messages template format: `feat: add something new` - USE `git diff HEAD sourceFilePath` to diff an uncommitted file against the last commit - NEVER capitalize words in commit messages -- ALWAYS run `git status` to check ALL uncommitted changes after completing any code edits, then reply with 3 commit message suggestions covering the ENTIRE uncommitted diff +- ALWAYS create a `*-backup` branch before performing a rebase +- ALWAYS suggest 3 commit messages with confidence score ratings, e.g. `fix: show toast on resolution (90%)`. In plan mode, include them at the end of the plan. If the user picks one via plan update, commit after implementation. Outside plan mode, suggest after implementation completes. In both cases, run `git status` to check ALL uncommitted changes after completing code edits - ALWAYS check existing code patterns before implementing new features - USE existing extensions and utilities rather than creating new ones -- ALWAYS consider applying YAGNI (You Ain't Gonna Need It) principle for new code +- ALWAYS use or create `Context` extension properties in `ext/Context.kt` instead of raw `context.getSystemService()` casts +- ALWAYS apply the YAGNI (You Ain't Gonna Need It) principle for new code - ALWAYS reuse existing constants - ALWAYS ensure a method exist before calling it - ALWAYS remove unused code after refactors @@ -179,6 +181,8 @@ suspend fun getData(): Result = withContext(Dispatchers.IO) { - ALWAYS pass the TAG as context to `Logger` calls, e.g. `Logger.debug("message", context = TAG)` - NEVER add `e = ` named parameter to Logger calls - NEVER manually append the `Throwable`'s message or any other props to the string passed as the 1st param of `Logger.*` calls, its internals are already enriching the final log message with the details of the `Throwable` passed via the `e` arg +- ALWAYS wrap parameter values in log messages with single quotes, e.g. `Logger.info("Received event '$eventName'", context = TAG)` +- ALWAYS start log messages with a verb, e.g. `Logger.info("Received payment for '$hash'", context = TAG)` - ALWAYS log errors at the final handling layer where the error is acted upon, not in intermediate layers that just propagate it - ALWAYS use the Result API instead of try-catch - NEVER wrap methods returning `Result` in try-catch @@ -194,7 +198,7 @@ suspend fun getData(): Result = withContext(Dispatchers.IO) { - PREFER declaring small dependant classes, constants, interfaces or top-level functions in the same file with the core class where these are used - ALWAYS create data classes for state AFTER viewModel class in same file - ALWAYS return early where applicable, PREFER guard-like `if` conditions like `if (condition) return` -- ALWAYS write the documentation for new features as markdown files in `docs/` +- USE `docs/` as target dir of saved files when asked to create documentation for new features - NEVER write code in the documentation files - NEVER add code comments to private functions, classes, etc - ALWAYS use `_uiState.update { }`, NEVER use `_stateFlow.value =` @@ -203,19 +207,30 @@ suspend fun getData(): Result = withContext(Dispatchers.IO) { - ALWAYS be mindful of thread safety when working with mutable lists & state - ALWAYS split screen composables into parent accepting viewmodel + inner private child accepting state and callbacks `Content()` - ALWAYS name lambda parameters in a composable function using present tense, NEVER use past tense -- NEVER use `wheneverBlocking` in unit test expression body functions wrapped in a `= test {}` lambda -- ALWAYS wrap unit tests `setUp` methods mocking suspending calls with `runBlocking`, e.g `setUp() = runBlocking {}` -- ALWAYS add business logic to Repository layer via methods returning `Result` and use it in ViewModels -- ALWAYS order upstream architectural data flow this way: `UI -> ViewModel -> Repository -> RUST` and vice-versa for downstream -- ALWAYS add new localizable string string resources in alphabetical order in `strings.xml` +- ALWAYS use `whenever { mock.suspendCall() }` for suspend stubs if not inside `test{}` fn blocks +- ALWAYS use `whenever(mock.call())` for non-suspend stubs and for suspend stubs if inside `test{}` fn blocks +- NEVER use the old, deprecated `wheneverBlocking` +- ALWAYS prefer `kotlin.test` asserts over `org.junit.Assert` in unit tests +- ALWAYS use a deterministic locale in unit tests to ensure consistent results across CI and local runs +- ALWAYS add a locale parameter with default value `Locale.getDefault()` to methods that depend on locale +- ALWAYS add business logic to repository layer via methods returning `Result` and use it in ViewModels +- ALWAYS order upstream architectural data flow this way: `UI -> ViewModel -> Repository -> RUST` and vice versa for downstream +- ALWAYS add new localizable string resources in alphabetical order in `strings.xml` - NEVER add string resources for strings used only in dev settings screens and previews and never localize acronyms - ALWAYS use template in `.github/pull_request_template.md` for PR descriptions - ALWAYS wrap `ULong` numbers with `USat` in arithmetic operations, to guard against overflows - PREFER to use one-liners with `run {}` when applicable, e.g. `override fun someCall(value: String) = run { this.value = value }` - ALWAYS add imports instead of inline fully-qualified names - PREFER to place `@Suppress()` annotations at the narrowest possible scope -- ALWAYS wrap suspend functions in `withContext(bgDispatcher)` if in domain layer, using ctor injected prop `@BgDispatcher private val bgDispatcher: CoroutineDispatcher` -- ALWAYS position companion object at the top of the class +- ALWAYS wrap suspend functions in `withContext(ioDispatcher)` if in domain layer, using ctor injected prop `@IoDispatcher private val ioDispatcher: CoroutineDispatcher` +- ALWAYS position `companion object` at the top of the class +- NEVER use `Exception` directly, use `AppError` instead +- ALWAYS inherit custom exceptions from `AppError` +- ALWAYS prefer `requireNotNull(someNullable) { "error message" }` or `checkNotNull { "someErrorMessage" }` over `!!` or `?: SomeAppError()` +- ALWAYS prefer Kotlin `Duration` for timeouts and delays +- ALWAYS prefer `when (subject)` with Kotlin guard conditions (`if`) over condition-based `when {}` with `is` type checks, e.g. `when (event) { is Foo if event.x == y -> ... }` instead of `when { event is Foo && event.x == y -> ... }` +- ALWAYS prefer `sealed interface` over `sealed class` when no shared state or constructor is needed +- NEVER duplicate error logging in `.onFailure {}` if the called method already logs the same error internally ### Device Debugging (adb) diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index 4e35ed4d2..2af729cc3 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -21,6 +21,8 @@ import to.bitkit.data.CacheStore import to.bitkit.di.UiDispatcher import to.bitkit.domain.commands.NotifyPaymentReceived import to.bitkit.domain.commands.NotifyPaymentReceivedHandler +import to.bitkit.domain.commands.NotifyPendingPaymentResolved +import to.bitkit.domain.commands.NotifyPendingPaymentResolvedHandler import to.bitkit.ext.activityManager import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NotificationDetails @@ -51,6 +53,9 @@ class LightningNodeService : Service() { @Inject lateinit var notifyPaymentReceivedHandler: NotifyPaymentReceivedHandler + @Inject + lateinit var notifyPendingPaymentResolvedHandler: NotifyPendingPaymentResolvedHandler + @Inject lateinit var cacheStore: CacheStore @@ -66,6 +71,7 @@ class LightningNodeService : Service() { eventHandler = { event -> Logger.debug("LDK-node event received in $TAG: ${jsonLogOf(event)}", context = TAG) handlePaymentReceived(event) + handlePendingPaymentResolved(event) } ).onSuccess { walletRepo.setWalletExistsState() @@ -99,6 +105,20 @@ class LightningNodeService : Service() { pushNotification(notification.title, notification.body) } + private suspend fun handlePendingPaymentResolved(event: Event) { + val command = NotifyPendingPaymentResolved.Command.from(event) ?: return + + notifyPendingPaymentResolvedHandler(command).onSuccess { + if (it !is NotifyPendingPaymentResolved.Result.ShowNotification) return + if (App.currentActivity?.value != null) { + Logger.debug("Skipping pending payment notification: activity is active", context = TAG) + return + } + Logger.debug("Showing pending payment notification for '${command.paymentHash}'", context = TAG) + pushNotification(it.notification.title, it.notification.body) + } + } + private fun createNotification( contentText: String = getString(R.string.notification__service__body), ): Notification { diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyPendingPaymentResolved.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPendingPaymentResolved.kt new file mode 100644 index 000000000..370ff4e59 --- /dev/null +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPendingPaymentResolved.kt @@ -0,0 +1,27 @@ +package to.bitkit.domain.commands + +import org.lightningdevkit.ldknode.Event +import to.bitkit.models.NotificationDetails + +sealed interface NotifyPendingPaymentResolved { + + sealed interface Command : NotifyPendingPaymentResolved { + val paymentHash: String + + data class Success(override val paymentHash: String) : Command + data class Failure(override val paymentHash: String) : Command + + companion object { + fun from(event: Event): Command? = when (event) { + is Event.PaymentSuccessful -> Success(event.paymentHash) + is Event.PaymentFailed -> event.paymentHash?.let { Failure(it) } + else -> null + } + } + } + + sealed interface Result : NotifyPendingPaymentResolved { + data class ShowNotification(val notification: NotificationDetails) : Result + data object Skip : Result + } +} diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyPendingPaymentResolvedHandler.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPendingPaymentResolvedHandler.kt new file mode 100644 index 000000000..d5f34a2d2 --- /dev/null +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPendingPaymentResolvedHandler.kt @@ -0,0 +1,44 @@ +package to.bitkit.domain.commands + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import to.bitkit.di.IoDispatcher +import to.bitkit.repositories.PendingPaymentNotification +import to.bitkit.repositories.PendingPaymentRepo +import to.bitkit.utils.Logger +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NotifyPendingPaymentResolvedHandler @Inject constructor( + @ApplicationContext private val context: Context, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val pendingPaymentRepo: PendingPaymentRepo, +) { + companion object { + const val TAG = "NotifyPendingPaymentResolvedHandler" + } + + suspend operator fun invoke( + command: NotifyPendingPaymentResolved.Command, + ): Result = withContext(ioDispatcher) { + runCatching { + if (!pendingPaymentRepo.isPending(command.paymentHash)) { + return@runCatching NotifyPendingPaymentResolved.Result.Skip + } + val notification = buildNotificationContent(command) + NotifyPendingPaymentResolved.Result.ShowNotification(notification) + }.onFailure { + Logger.error("Failed to process pending payment notification", it, context = TAG) + } + } + + private fun buildNotificationContent( + command: NotifyPendingPaymentResolved.Command, + ) = when (command) { + is NotifyPendingPaymentResolved.Command.Success -> PendingPaymentNotification.success(context) + is NotifyPendingPaymentResolved.Command.Failure -> PendingPaymentNotification.error(context) + } +} diff --git a/app/src/main/java/to/bitkit/ext/Flows.kt b/app/src/main/java/to/bitkit/ext/Flows.kt index 3090d0fc8..d31c0c899 100644 --- a/app/src/main/java/to/bitkit/ext/Flows.kt +++ b/app/src/main/java/to/bitkit/ext/Flows.kt @@ -4,35 +4,43 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.time.Duration +import kotlin.time.Duration.Companion.INFINITE /** * Suspends and collects the elements of the Flow until the provided predicate satisfies * a `WatchResult.Complete`. * + * @param timeout Maximum duration to wait before returning null. Defaults to [Duration.INFINITE]. * @param predicate A suspending function that processes each emitted value and returns a * `WatchResult` indicating whether to continue or complete with a result. - * @return The result of type `R` when the `WatchResult.Complete` is returned by the predicate. + * @return The result of type `R` when the `WatchResult.Complete` is returned by the predicate, + * or null if the timeout elapses first. */ suspend inline fun Flow.watchUntil( + timeout: Duration = INFINITE, crossinline predicate: suspend (T) -> WatchResult, -): R { - val result = CompletableDeferred() +): R? { + return withTimeoutOrNull(timeout) { + val result = CompletableDeferred() - this.takeWhile { value -> - when (val eventResult = predicate(value)) { - is WatchResult.Continue -> { - eventResult.result?.let { result.complete(it) } - true - } + this@watchUntil.takeWhile { value -> + when (val eventResult = predicate(value)) { + is WatchResult.Continue -> { + eventResult.result?.let { result.complete(it) } + true + } - is WatchResult.Complete -> { - result.complete(eventResult.result) - false + is WatchResult.Complete -> { + result.complete(eventResult.result) + false + } } - } - }.collect() + }.collect() - return result.await() + result.await() + } } sealed interface WatchResult { diff --git a/app/src/main/java/to/bitkit/models/Currency.kt b/app/src/main/java/to/bitkit/models/Currency.kt index b37612564..7925a03ed 100644 --- a/app/src/main/java/to/bitkit/models/Currency.kt +++ b/app/src/main/java/to/bitkit/models/Currency.kt @@ -158,10 +158,11 @@ fun BigDecimal.formatCurrencyWithSymbol( currencySymbol: String? = null, withSpace: Boolean = false, decimalPlaces: Int = FIAT_DECIMALS, + locale: Locale = Locale.getDefault(), ): String { val formatted = formatCurrency(decimalPlaces) ?: "0.00" val symbol = currencySymbol - ?: runCatching { java.util.Currency.getInstance(currencyCode) }.getOrNull()?.symbol + ?: runCatching { java.util.Currency.getInstance(currencyCode) }.getOrNull()?.getSymbol(locale) ?: currencyCode val separator = if (withSpace) " " else "" diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index fe0fc6399..3c9ecdc05 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -448,7 +448,7 @@ class LightningRepo @Inject constructor( // If node is still running, revert to Running state to allow retry if (lightningService.node != null && lightningService.status?.isRunning == true) { Logger.warn("Stop failed but node is still running, reverting to Running state", context = TAG) - _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Running) } + _lightningState.update { s -> s.copy(nodeLifecycleState = NodeLifecycleState.Running) } } else { // Node appears stopped, update state _lightningState.update { LightningState(nodeLifecycleState = NodeLifecycleState.Stopped) } @@ -1377,6 +1377,7 @@ class LightningRepo @Inject constructor( private const val SYNC_RETRY_DELAY_MS = 15_000L private const val CHANNELS_READY_TIMEOUT_MS = 15_000L private const val CHANNELS_USABLE_TIMEOUT_MS = 15_000L + val SEND_LN_TIMEOUT = 10.seconds } } diff --git a/app/src/main/java/to/bitkit/repositories/PendingPaymentRepo.kt b/app/src/main/java/to/bitkit/repositories/PendingPaymentRepo.kt new file mode 100644 index 000000000..007c7b2f7 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PendingPaymentRepo.kt @@ -0,0 +1,64 @@ +package to.bitkit.repositories + +import android.content.Context +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import to.bitkit.R +import to.bitkit.models.NotificationDetails +import to.bitkit.utils.AppError +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PendingPaymentRepo @Inject constructor() { + + private val _state = MutableStateFlow(PendingPaymentsState()) + val state = _state.asStateFlow() + + private val _resolution = MutableSharedFlow(extraBufferCapacity = 1) + val resolution = _resolution.asSharedFlow() + + fun track(paymentHash: String) { + _state.update { it.copy(pendingPayments = it.pendingPayments + paymentHash) } + } + + fun isPending(hash: String): Boolean = _state.value.pendingPayments.contains(hash) + + suspend fun resolve(resolution: PendingPaymentResolution) { + _state.update { it.copy(pendingPayments = it.pendingPayments - resolution.paymentHash) } + _resolution.emit(resolution) + } + + fun setActiveHash(hash: String?) = _state.update { it.copy(activeHash = hash) } + + fun isActive(hash: String): Boolean = _state.value.activeHash == hash +} + +data class PendingPaymentsState( + val pendingPayments: Set = emptySet(), + val activeHash: String? = null, +) + +class PaymentPendingException(val paymentHash: String) : AppError("Payment pending") + +sealed interface PendingPaymentResolution { + val paymentHash: String + + data class Success(override val paymentHash: String) : PendingPaymentResolution + data class Failure(override val paymentHash: String) : PendingPaymentResolution +} + +object PendingPaymentNotification { + fun success(context: Context) = NotificationDetails( + title = context.getString(R.string.wallet__toast_payment_sent_title), + body = context.getString(R.string.wallet__toast_payment_sent_description), + ) + + fun error(context: Context) = NotificationDetails( + title = context.getString(R.string.wallet__toast_payment_failed_title), + body = context.getString(R.string.wallet__toast_payment_failed_description), + ) +} diff --git a/app/src/main/java/to/bitkit/ui/Notifications.kt b/app/src/main/java/to/bitkit/ui/Notifications.kt index cda3cefa0..ec9e35293 100644 --- a/app/src/main/java/to/bitkit/ui/Notifications.kt +++ b/app/src/main/java/to/bitkit/ui/Notifications.kt @@ -52,7 +52,8 @@ internal fun Context.notificationBuilder( val pendingIntent = PendingIntent.getActivity(this, 0, intent, flags) return NotificationCompat.Builder(this, channelId) - .setSmallIcon(R.drawable.ic_launcher_fg_regtest) + .setSmallIcon(R.drawable.ic_bitkit_outlined) + .setColor(ContextCompat.getColor(this, R.color.brand)) .setPriority(NotificationCompat.PRIORITY_HIGH) .setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)) .setContentIntent(pendingIntent) // fired on tap diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt index 581c48b00..464b73fe2 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt @@ -187,6 +187,8 @@ class ExternalNodeViewModel @Inject constructor( else -> WatchResult.Continue() } + }.let { + checkNotNull(it) { "Timeout in awaitChannelPendingEvent for userChannelId='$userChannelId'" } } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendErrorScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendErrorScreen.kt index 9ee8437f9..c65190b5c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendErrorScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendErrorScreen.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -20,8 +19,10 @@ import androidx.compose.ui.unit.dp import to.bitkit.R import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BottomSheetPreview +import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground @@ -30,12 +31,12 @@ import to.bitkit.ui.theme.Colors @Composable fun SendErrorScreen( - errorMessage: String, + message: String?, onRetry: () -> Unit, onClose: () -> Unit, ) { Content( - errorMessage = errorMessage, + message, onRetry = onRetry, onClose = onClose, ) @@ -43,12 +44,11 @@ fun SendErrorScreen( @Composable private fun Content( - errorMessage: String, + message: String?, modifier: Modifier = Modifier, onRetry: () -> Unit = {}, onClose: () -> Unit = {}, ) { - val errorText = errorMessage.ifEmpty { "Unknown error." } Column( modifier = modifier .fillMaxSize() @@ -62,11 +62,13 @@ private fun Content( .fillMaxSize() .padding(horizontal = 16.dp) ) { - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) - BodyM(text = errorText, color = Colors.White64) + message?.let { + BodyM(it, color = Colors.White64) + } - Spacer(modifier = Modifier.weight(1f)) + FillHeight() Image( painter = painterResource(R.drawable.cross), contentDescription = null, @@ -74,7 +76,7 @@ private fun Content( .fillMaxWidth() .height(256.dp) ) - Spacer(modifier = Modifier.weight(1f)) + FillHeight() Row( horizontalArrangement = Arrangement.spacedBy(16.dp), @@ -90,11 +92,13 @@ private fun Content( PrimaryButton( text = stringResource(R.string.common__try_again), onClick = onRetry, - modifier = Modifier.weight(1f) + modifier = Modifier + .weight(1f) + .testTag("Retry") ) } - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) } } } @@ -105,7 +109,7 @@ private fun Preview() { AppThemeSurface { BottomSheetPreview { Content( - errorMessage = stringResource(R.string.wallet__send_error_create_tx), + message = stringResource(R.string.wallet__send_error_create_tx), modifier = Modifier.sheetHeight(), ) } @@ -118,7 +122,7 @@ private fun PreviewUnknown() { AppThemeSurface { BottomSheetPreview { Content( - errorMessage = "", + message = null, modifier = Modifier.sheetHeight(), ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt new file mode 100644 index 000000000..ead96da93 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt @@ -0,0 +1,160 @@ +package to.bitkit.ui.screens.wallets.send + +import androidx.compose.animation.core.EaseInOut +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +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.layout.size +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.draw.rotate +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import to.bitkit.R +import to.bitkit.repositories.PendingPaymentResolution +import to.bitkit.ui.components.BalanceHeaderView +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BottomSheetPreview +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors + +@Composable +fun SendPendingScreen( + paymentHash: String, + amount: Long, + onPaymentSuccess: (String) -> Unit, + onPaymentError: () -> Unit, + onClose: () -> Unit, + onViewDetails: (String) -> Unit, + viewModel: SendPendingViewModel, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { viewModel.init(paymentHash, amount) } + + uiState.resolution?.let { resolution -> + LaunchedEffect(resolution) { + when (resolution) { + is PendingPaymentResolution.Success -> onPaymentSuccess(resolution.paymentHash) + is PendingPaymentResolution.Failure -> onPaymentError() + } + viewModel.onResolutionHandled() + } + } + + Content( + amount = uiState.amount, + activityId = uiState.activityId, + onClose = onClose, + onViewDetails = onViewDetails, + ) +} + +@Composable +private fun Content( + amount: Long, + activityId: String?, + onClose: () -> Unit, + onViewDetails: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + ) { + SheetTopBar(stringResource(R.string.wallet__send_pending__nav_title)) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + VerticalSpacer(16.dp) + BalanceHeaderView(sats = amount, modifier = Modifier.fillMaxWidth()) + + VerticalSpacer(32.dp) + BodyM(stringResource(R.string.wallet__send_pending__description), color = Colors.White64) + + FillHeight() + HourglassAnimation(modifier = Modifier.align(Alignment.CenterHorizontally)) + FillHeight() + + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + SecondaryButton( + text = stringResource(R.string.wallet__send_details), + enabled = activityId != null, + onClick = { activityId?.let(onViewDetails) }, + modifier = Modifier.weight(1f), + ) + PrimaryButton( + text = stringResource(R.string.common__close), + onClick = onClose, + modifier = Modifier.weight(1f), + ) + } + VerticalSpacer(16.dp) + } + } +} + +@Composable +private fun HourglassAnimation(modifier: Modifier = Modifier) { + val infiniteTransition = rememberInfiniteTransition(label = "hourglass") + val rotation by infiniteTransition.animateFloat( + initialValue = -16f, + targetValue = 16f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 3000, easing = EaseInOut), + repeatMode = RepeatMode.Reverse, + ), + label = "hourglassRotation", + ) + Image( + painter = painterResource(R.drawable.hourglass), + contentDescription = null, + modifier = modifier + .size(256.dp) + .rotate(rotation), + ) +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + BottomSheetPreview { + Content( + amount = 50_000L, + activityId = null, + onClose = {}, + onViewDetails = {}, + modifier = Modifier.sheetHeight(), + ) + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt new file mode 100644 index 000000000..98643c683 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt @@ -0,0 +1,82 @@ +package to.bitkit.ui.screens.wallets.send + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.synonym.bitkitcore.ActivityFilter +import com.synonym.bitkitcore.PaymentType +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import to.bitkit.ext.rawId +import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.PendingPaymentRepo +import to.bitkit.repositories.PendingPaymentResolution +import to.bitkit.utils.Logger +import javax.inject.Inject + +@HiltViewModel +class SendPendingViewModel @Inject constructor( + private val pendingPaymentRepo: PendingPaymentRepo, + private val activityRepo: ActivityRepo, +) : ViewModel() { + + companion object { + private const val TAG = "SendPendingViewModel" + } + + private val _uiState = MutableStateFlow(SendPendingUiState()) + val uiState = _uiState.asStateFlow() + + private var isInitialized = false + + fun init(paymentHash: String, amount: Long) { + if (isInitialized) return + isInitialized = true + pendingPaymentRepo.setActiveHash(paymentHash) + _uiState.update { it.copy(amount = amount) } + findActivity(paymentHash) + observeResolution(paymentHash) + } + + override fun onCleared() { + pendingPaymentRepo.setActiveHash(null) + } + + fun onResolutionHandled() = _uiState.update { it.copy(resolution = null) } + + private fun findActivity(paymentHash: String) { + viewModelScope.launch { + activityRepo.findActivityByPaymentId( + paymentHashOrTxId = paymentHash, + type = ActivityFilter.LIGHTNING, + txType = PaymentType.SENT, + retry = true, + ).onSuccess { + _uiState.update { state -> state.copy(activityId = it.rawId()) } + } + } + } + + private fun observeResolution(paymentHash: String) { + viewModelScope.launch { + pendingPaymentRepo.resolution + .filter { it.paymentHash == paymentHash } + .collect { resolution -> + Logger.info( + "Received payment resolution '${resolution::class.simpleName}' for '$paymentHash'", + context = TAG, + ) + _uiState.update { it.copy(resolution = resolution) } + } + } + } +} + +data class SendPendingUiState( + val amount: Long = 0L, + val activityId: String? = null, + val resolution: PendingPaymentResolution? = null, +) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendQuickPayScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendQuickPayScreen.kt index bcaef17eb..201896a2c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendQuickPayScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendQuickPayScreen.kt @@ -39,6 +39,7 @@ import to.bitkit.viewmodels.QuickPayViewModel fun SendQuickPayScreen( quickPayData: QuickPayData, onPaymentComplete: (String, Long) -> Unit, + onPaymentPending: (String, Long) -> Unit, onShowError: (String) -> Unit, viewModel: QuickPayViewModel = hiltViewModel(), ) { @@ -61,6 +62,7 @@ fun SendQuickPayScreen( LaunchedEffect(uiState.result) { when (val result = uiState.result) { is QuickPayResult.Success -> onPaymentComplete(result.paymentHash, result.amountWithFee) + is QuickPayResult.Pending -> onPaymentPending(result.paymentHash, result.amount) is QuickPayResult.Error -> onShowError(result.message) null -> Unit // continue showing loading state } diff --git a/app/src/main/java/to/bitkit/ui/shared/toast/ToastEventBus.kt b/app/src/main/java/to/bitkit/ui/shared/toast/ToastEventBus.kt index 57fe9f4af..4ce86166b 100644 --- a/app/src/main/java/to/bitkit/ui/shared/toast/ToastEventBus.kt +++ b/app/src/main/java/to/bitkit/ui/shared/toast/ToastEventBus.kt @@ -8,6 +8,7 @@ object ToastEventBus { private val _events = MutableSharedFlow(extraBufferCapacity = 1) val events = _events.asSharedFlow() + @Suppress("LongParameterList") suspend fun send( type: Toast.ToastType, title: String, 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 5f9916c3f..5af5e4449 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -31,6 +31,8 @@ import to.bitkit.ui.screens.wallets.send.SendErrorScreen import to.bitkit.ui.screens.wallets.send.SendFeeCustomScreen import to.bitkit.ui.screens.wallets.send.SendFeeRateScreen import to.bitkit.ui.screens.wallets.send.SendFeeViewModel +import to.bitkit.ui.screens.wallets.send.SendPendingScreen +import to.bitkit.ui.screens.wallets.send.SendPendingViewModel import to.bitkit.ui.screens.wallets.send.SendPinCheckScreen import to.bitkit.ui.screens.wallets.send.SendQuickPayScreen import to.bitkit.ui.screens.wallets.send.SendRecipientScreen @@ -80,7 +82,7 @@ fun SendSheet( is SendEffect.PaymentSuccess -> { appViewModel.clearClipboardForAutoRead() navController.navigate(SendRoute.Success) { - popUpTo(startDestination) { inclusive = true } + popUpTo(navController.graph.id) { inclusive = true } } } @@ -90,6 +92,9 @@ fun SendSheet( is SendEffect.NavigateToFee -> navController.navigate(SendRoute.FeeRate) is SendEffect.NavigateToFeeCustom -> navController.navigate(SendRoute.FeeCustom) is SendEffect.NavigateToComingSoon -> navController.navigate(SendRoute.ComingSoon) + is SendEffect.NavigateToPending -> navController.navigate( + SendRoute.Pending(it.paymentHash, it.amount) + ) { popUpTo(startDestination) { inclusive = true } } } } } @@ -254,7 +259,7 @@ fun SendSheet( SendQuickPayScreen( quickPayData = requireNotNull(quickPayData), onPaymentComplete = { paymentHash, amountWithFee -> - appViewModel.handlePaymentSuccess( + appViewModel.onSendSuccess( NewTransactionSheetDetails( type = NewTransactionSheetType.LIGHTNING, direction = NewTransactionSheetDirection.SENT, @@ -263,11 +268,41 @@ fun SendSheet( ), ) }, + onPaymentPending = { paymentHash, amount -> + navController.navigate(SendRoute.Pending(paymentHash, amount)) { + popUpTo(startDestination) { inclusive = true } + } + }, onShowError = { errorMessage -> navController.navigate(SendRoute.Error(errorMessage)) } ) } + composableWithDefaultTransitions { + val route = it.toRoute() + SendPendingScreen( + paymentHash = route.paymentHash, + amount = route.amount, + onPaymentSuccess = { paymentHash -> + appViewModel.onSendSuccess( + NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.SENT, + paymentHashOrTxId = paymentHash, + sats = route.amount, + ), + ) + }, + onPaymentError = { + navController.navigate(SendRoute.Error()) { + popUpTo { inclusive = true } + } + }, + onClose = { appViewModel.hideSheet() }, + onViewDetails = { rawId -> appViewModel.navigateToActivity(rawId) }, + viewModel = hiltViewModel(), + ) + } composableWithDefaultTransitions { ComingSoonSheetContent( onWalletOverviewClick = { appViewModel.hideSheet() }, @@ -277,14 +312,10 @@ fun SendSheet( composableWithDefaultTransitions { val route = it.toRoute() SendErrorScreen( - errorMessage = route.errorMessage, + message = route.message, onRetry = { - if (startDestination == SendRoute.Recipient) { - navController.navigate(SendRoute.Recipient) { - popUpTo { inclusive = true } - } - } else { - navController.navigate(SendRoute.Success) + navController.navigate(SendRoute.Recipient) { + popUpTo(navController.graph.id) { inclusive = true } } }, onClose = { @@ -349,5 +380,8 @@ sealed interface SendRoute { data object ComingSoon : SendRoute @Serializable - data class Error(val errorMessage: String) : SendRoute + data class Pending(val paymentHash: String, val amount: Long) : SendRoute + + @Serializable + data class Error(val message: String? = null) : SendRoute } diff --git a/app/src/main/java/to/bitkit/ui/utils/ActivityItems.kt b/app/src/main/java/to/bitkit/ui/utils/ActivityItems.kt index b6d02ce4d..5ac918bf0 100644 --- a/app/src/main/java/to/bitkit/ui/utils/ActivityItems.kt +++ b/app/src/main/java/to/bitkit/ui/utils/ActivityItems.kt @@ -1,6 +1,7 @@ package to.bitkit.ui.utils import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.PaymentState import to.bitkit.R import to.bitkit.ext.isSent import to.bitkit.ext.isTransfer @@ -8,6 +9,10 @@ import to.bitkit.ext.isTransfer fun Activity.getScreenTitleRes(): Int { val isSent = this.isSent() + if (this is Activity.Lightning && isSent && v1.status == PaymentState.PENDING) { + return R.string.wallet__activity_pending_nav_title + } + var resId = when { isSent -> R.string.wallet__activity_bitcoin_sent else -> R.string.wallet__activity_bitcoin_received diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 0da507768..2fdc4fc85 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.Intent import android.net.Uri import androidx.annotation.StringRes +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -103,6 +104,10 @@ import to.bitkit.repositories.ConnectivityState import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.HealthRepo import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.PaymentPendingException +import to.bitkit.repositories.PendingPaymentNotification +import to.bitkit.repositories.PendingPaymentRepo +import to.bitkit.repositories.PendingPaymentResolution import to.bitkit.repositories.PreActivityMetadataRepo import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo @@ -116,6 +121,7 @@ import to.bitkit.ui.shared.toast.ToastQueueManager import to.bitkit.ui.sheets.SendRoute import to.bitkit.ui.theme.TRANSITION_SCREEN_MS import to.bitkit.usecases.FormatMoneyValue +import to.bitkit.utils.AppError import to.bitkit.utils.Bip21Utils import to.bitkit.utils.Logger import to.bitkit.utils.NetworkValidationHelper @@ -143,6 +149,7 @@ class AppViewModel @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val keychain: Keychain, private val lightningRepo: LightningRepo, + private val pendingPaymentRepo: PendingPaymentRepo, private val walletRepo: WalletRepo, private val backupRepo: BackupRepo, private val settingsStore: SettingsStore, @@ -428,6 +435,7 @@ class AppViewModel @Inject constructor( lightningRepo.pruneEmptyAddressTypesAfterRestore() walletRepo.debounceSyncByEvent() } + !isShowingLoading && !needsPostMigrationSync && !isCompletingMigration -> walletRepo.debounceSyncByEvent() else -> Unit } @@ -586,6 +594,13 @@ class AppViewModel @Inject constructor( private suspend fun handlePaymentFailed(event: Event.PaymentFailed) { event.paymentHash?.let { paymentHash -> activityRepo.handlePaymentEvent(paymentHash) + if (pendingPaymentRepo.isPending(paymentHash)) { + pendingPaymentRepo.resolve(PendingPaymentResolution.Failure(paymentHash)) + if (_currentSheet.value !is Sheet.Send || !pendingPaymentRepo.isActive(paymentHash)) { + notifyPendingPaymentFailed() + } + return + } } notifyPaymentFailed(event.reason) } @@ -600,6 +615,13 @@ class AppViewModel @Inject constructor( private suspend fun handlePaymentSuccessful(event: Event.PaymentSuccessful) { event.paymentHash.let { paymentHash -> activityRepo.handlePaymentEvent(paymentHash) + if (pendingPaymentRepo.isPending(paymentHash)) { + pendingPaymentRepo.resolve(PendingPaymentResolution.Success(paymentHash)) + if (_currentSheet.value !is Sheet.Send || !pendingPaymentRepo.isActive(paymentHash)) { + notifyPendingPaymentSucceeded() + } + return + } } notifyPaymentSentOnLightning(event) } @@ -675,6 +697,24 @@ class AppViewModel @Inject constructor( ) } + private fun notifyPendingPaymentSucceeded() = PendingPaymentNotification.success(context).let { + toast( + type = Toast.ToastType.LIGHTNING, + title = it.title, + description = it.body, + testTag = "PendingPaymentSucceededToast", + ) + } + + private fun notifyPendingPaymentFailed() = PendingPaymentNotification.error(context).let { + toast( + type = Toast.ToastType.ERROR, + title = it.title, + description = it.body, + testTag = "PendingPaymentFailedToast", + ) + } + private fun notifyPaymentFailed(reason: PaymentFailureReason? = null) = toast( type = Toast.ToastType.ERROR, title = context.getString(R.string.wallet__toast_payment_failed_title), @@ -691,7 +731,7 @@ class AppViewModel @Inject constructor( txType = PaymentType.SENT, retry = true ).onSuccess { activity -> - handlePaymentSuccess( + onSendSuccess( NewTransactionSheetDetails( type = NewTransactionSheetType.LIGHTNING, direction = NewTransactionSheetDirection.SENT, @@ -699,8 +739,8 @@ class AppViewModel @Inject constructor( sats = activity.totalValue().toLong(), ), ) - }.onFailure { e -> - Logger.warn("Failed displaying sheet for event: $event", e) + }.onFailure { + Logger.warn("Failed displaying sheet for event: $event", it, context = TAG) } } @@ -1638,7 +1678,7 @@ class AppViewModel @Inject constructor( sendOnchain(address, amount, tags = tags) .onSuccess { txId -> Logger.info("Onchain send result txid: $txId", context = TAG) - handlePaymentSuccess( + onSendSuccess( NewTransactionSheetDetails( type = NewTransactionSheetType.ONCHAIN, direction = NewTransactionSheetDirection.SENT, @@ -1689,7 +1729,7 @@ class AppViewModel @Inject constructor( sendLightning(bolt11, paymentAmount).onSuccess { actualPaymentHash -> Logger.info("Lightning send result payment hash: $actualPaymentHash", context = TAG) - handlePaymentSuccess( + onSendSuccess( NewTransactionSheetDetails( type = NewTransactionSheetType.LIGHTNING, direction = NewTransactionSheetDirection.SENT, @@ -1697,13 +1737,19 @@ class AppViewModel @Inject constructor( sats = paymentAmount.toLong(), // TODO Add fee when available ), ) - }.onFailure { e -> + }.onFailure { + if (it is PaymentPendingException) { + Logger.info("Lightning payment pending", context = TAG) + pendingPaymentRepo.track(it.paymentHash) + setSendEffect(SendEffect.NavigateToPending(it.paymentHash, paymentAmount.toLong())) + return@onFailure + } // Delete pre-activity metadata on failure if (createdMetadataPaymentId != null) { preActivityMetadataRepo.deletePreActivityMetadata(createdMetadataPaymentId) } - Logger.error("Error sending lightning payment", e, context = TAG) - toast(e) + Logger.error("Error sending lightning payment", it, context = TAG) + toast(it) hideSheet() } } @@ -1828,30 +1874,18 @@ class AppViewModel @Inject constructor( amount: ULong? = null, ): Result { return lightningRepo.payInvoice(bolt11 = bolt11, sats = amount).onSuccess { hash -> - // Wait until matching payment event is received - val result = lightningRepo.nodeEvents.watchUntil { event -> - when (event) { - is Event.PaymentSuccessful -> { - if (event.paymentHash == hash) { - WatchResult.Complete(Result.success(hash)) - } else { - WatchResult.Continue() - } - } - - is Event.PaymentFailed -> { - if (event.paymentHash == hash) { - val error = Exception(event.reason.toUserMessage(context)) - WatchResult.Complete(Result.failure(error)) - } else { - WatchResult.Continue() - } - } + // Wait until matching payment event is received (with timeout for hold invoices) + val result = lightningRepo.nodeEvents.watchUntil(LightningRepo.SEND_LN_TIMEOUT) { + when (it) { + is Event.PaymentSuccessful if it.paymentHash == hash -> WatchResult.Complete(Result.success(hash)) + is Event.PaymentFailed if it.paymentHash == hash -> WatchResult.Complete( + Result.failure(AppError(it.reason.toUserMessage(context))) + ) else -> WatchResult.Continue() } } - return result + return result ?: Result.failure(PaymentPendingException(hash)) } } @@ -1866,6 +1900,13 @@ class AppViewModel @Inject constructor( fun resetQuickPay() = _quickPayData.update { null } + fun navigateToActivity(activityRawId: String) { + viewModelScope.launch { + hideSheet() + mainScreenEffect(MainScreenEffect.Navigate(Routes.ActivityDetail(activityRawId))) + } + } + /** Reselect utxos for current amount & speed then refresh fees using updated utxos */ private fun refreshOnchainSendIfNeeded() { val currentState = _sendUiState.value @@ -2237,7 +2278,7 @@ class AppViewModel @Inject constructor( } } - fun handlePaymentSuccess(details: NewTransactionSheetDetails) { + fun onSendSuccess(details: NewTransactionSheetDetails) { details.paymentHashOrTxId?.let { if (!processedPayments.add(it)) { Logger.debug("Payment $it already processed, skipping duplicate", context = TAG) @@ -2246,7 +2287,7 @@ class AppViewModel @Inject constructor( } _successSendUiState.update { details } - setSendEffect(SendEffect.PaymentSuccess(details)) + setSendEffect(SendEffect.PaymentSuccess) } fun handleDeeplinkIntent(intent: Intent) { @@ -2389,7 +2430,8 @@ sealed class SendEffect { data object NavigateToFee : SendEffect() data object NavigateToFeeCustom : SendEffect() data object NavigateToComingSoon : SendEffect() - data class PaymentSuccess(val sheet: NewTransactionSheetDetails? = null) : SendEffect() + data object PaymentSuccess : SendEffect() + data class NavigateToPending(val paymentHash: String, val amount: Long) : SendEffect() } sealed class MainScreenEffect { @@ -2437,10 +2479,14 @@ sealed interface LnurlParams { data class LnurlWithdraw(val data: LnurlWithdrawData) : LnurlParams } +@Stable sealed interface QuickPayData { val sats: ULong + @Stable data class Bolt11(override val sats: ULong, val bolt11: String) : QuickPayData + + @Stable data class LnurlPay(override val sats: ULong, val callback: String) : QuickPayData } // endregion diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index 28da59246..feda0ed8e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -15,6 +15,9 @@ import to.bitkit.ext.WatchResult import to.bitkit.ext.toUserMessage import to.bitkit.ext.watchUntil import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.PaymentPendingException +import to.bitkit.repositories.PendingPaymentRepo +import to.bitkit.utils.AppError import to.bitkit.utils.Logger import javax.inject.Inject @@ -22,16 +25,21 @@ import javax.inject.Inject class QuickPayViewModel @Inject constructor( @ApplicationContext private val context: Context, private val lightningRepo: LightningRepo, + private val pendingPaymentRepo: PendingPaymentRepo, ) : ViewModel() { + companion object { + private const val TAG = "QuickPayViewModel" + } + private val _uiState = MutableStateFlow(QuickPayUiState()) val uiState = _uiState.asStateFlow() val lightningState = lightningRepo.lightningState - fun pay(quickPayData: QuickPayData) { + fun pay(data: QuickPayData) { viewModelScope.launch { - val (bolt11, amount) = when (val data = quickPayData) { + val (bolt11, amount) = when (data) { is QuickPayData.Bolt11 -> { Logger.info("QuickPay: processing bolt11 invoice") data.bolt11 to data.sats @@ -46,7 +54,7 @@ class QuickPayViewModel @Inject constructor( } return@launch } - invoice.bolt11 to quickPayData.sats + invoice.bolt11 to data.sats } } @@ -62,7 +70,20 @@ class QuickPayViewModel @Inject constructor( ) } }.onFailure { error -> - Logger.error("QuickPay lightning payment failed", error) + if (error is PaymentPendingException) { + Logger.info("QuickPay lightning payment pending", context = TAG) + pendingPaymentRepo.track(error.paymentHash) + _uiState.update { + it.copy( + result = QuickPayResult.Pending( + paymentHash = error.paymentHash, + amount = amount.toLong(), + ) + ) + } + return@onFailure + } + Logger.error("QuickPay lightning payment failed", error, context = TAG) _uiState.update { it.copy(result = QuickPayResult.Error(error.message.orEmpty())) @@ -81,30 +102,18 @@ class QuickPayViewModel @Inject constructor( } .getOrDefault("") - // Wait until matching payment event is received - val result = lightningRepo.nodeEvents.watchUntil { event -> - when (event) { - is Event.PaymentSuccessful -> { - if (event.paymentHash == hash) { - WatchResult.Complete(Result.success(hash)) - } else { - WatchResult.Continue() - } - } - - is Event.PaymentFailed -> { - if (event.paymentHash == hash) { - val error = Exception(event.reason.toUserMessage(context)) - WatchResult.Complete(Result.failure(error)) - } else { - WatchResult.Continue() - } - } + // Wait until matching payment event is received (with timeout for hold invoices) + val result = lightningRepo.nodeEvents.watchUntil(LightningRepo.SEND_LN_TIMEOUT) { + when (it) { + is Event.PaymentSuccessful if it.paymentHash == hash -> WatchResult.Complete(Result.success(hash)) + is Event.PaymentFailed if it.paymentHash == hash -> WatchResult.Complete( + Result.failure(AppError(it.reason.toUserMessage(context))) + ) else -> WatchResult.Continue() } } - return result + return result ?: Result.failure(PaymentPendingException(hash)) } } @@ -114,6 +123,11 @@ sealed class QuickPayResult { val amountWithFee: Long, ) : QuickPayResult() + data class Pending( + val paymentHash: String, + val amount: Long, + ) : QuickPayResult() + data class Error(val message: String) : QuickPayResult() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7115a659a..2128d6c51 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -807,6 +807,7 @@ Payment Payment hash Pending + Payment Pending Preimage Previous month Received @@ -944,6 +945,8 @@ MAX The maximum spendable amount is a bit lower due to a required reserve balance. Reserve Balance + This payment is taking a bit longer than expected. You can continue using Bitkit. + Payment Pending QuickPay Paying\n<accent>invoice...</accent> Review & Send @@ -967,6 +970,8 @@ Could not find a payment path to the recipient. Payment timed out. Please try again. Payment Failed + Your instant payment was sent successfully. + Payment Sent Your received transaction was replaced by a fee bump Received Transaction Replaced Transaction was removed from mempool diff --git a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt index 918e31ac8..0e7bbafc3 100644 --- a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt +++ b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt @@ -4,7 +4,6 @@ import android.Manifest import android.app.Activity import android.app.Application import android.app.Notification -import android.app.NotificationManager import android.content.Context import androidx.test.core.app.ApplicationProvider import com.google.firebase.messaging.FirebaseMessaging @@ -17,9 +16,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.runBlocking import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull import org.junit.Before import org.junit.Rule import org.junit.Test @@ -47,6 +43,9 @@ import to.bitkit.di.DispatchersModule import to.bitkit.di.ViewModelModule import to.bitkit.domain.commands.NotifyPaymentReceived import to.bitkit.domain.commands.NotifyPaymentReceivedHandler +import to.bitkit.domain.commands.NotifyPendingPaymentResolved +import to.bitkit.domain.commands.NotifyPendingPaymentResolvedHandler +import to.bitkit.ext.notificationManager import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType @@ -56,6 +55,9 @@ import to.bitkit.repositories.WalletRepo import to.bitkit.services.NodeEventHandler import to.bitkit.test.BaseUnitTest import to.bitkit.ui.shared.toast.ToastQueueManager +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull @HiltAndroidTest @UninstallModules(DispatchersModule::class, DbModule::class, ViewModelModule::class) @@ -81,6 +83,9 @@ class LightningNodeServiceTest : BaseUnitTest() { @BindValue val notifyPaymentReceivedHandler = mock() + @BindValue + val notifyPendingPaymentResolvedHandler = mock() + @BindValue val cacheStore = mock() @@ -146,7 +151,7 @@ class LightningNodeServiceTest : BaseUnitTest() { controller.create().startCommand(0, 0) testScheduler.advanceUntilIdle() - assertNotNull("Event handler should be captured", capturedHandler) + assertNotNull(capturedHandler, "Event handler should be captured") val event = Event.PaymentReceived( paymentId = "payment_id", @@ -158,13 +163,13 @@ class LightningNodeServiceTest : BaseUnitTest() { capturedHandler?.invoke(event) testScheduler.advanceUntilIdle() - val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val notificationManager = context.notificationManager val shadows = Shadows.shadowOf(notificationManager) val paymentNotification = shadows.allNotifications.find { it.extras.getString(Notification.EXTRA_TITLE) == context.getString(R.string.notification__received__title) } - assertNotNull("Payment notification should be present", paymentNotification) + assertNotNull(paymentNotification, "Payment notification should be present") val expected = NewTransactionSheetDetails( type = NewTransactionSheetType.LIGHTNING, @@ -195,14 +200,14 @@ class LightningNodeServiceTest : BaseUnitTest() { capturedHandler?.invoke(event) testScheduler.advanceUntilIdle() - val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val notificationManager = context.notificationManager val shadows = Shadows.shadowOf(notificationManager) val paymentNotification = shadows.allNotifications.find { it.extras.getString(Notification.EXTRA_TITLE) == context.getString(R.string.notification__received__title) } - assertNull("Payment notification should NOT be present in foreground", paymentNotification) + assertNull(paymentNotification, "Payment notification should NOT be present in foreground") verify(cacheStore, never()).setBackgroundReceive(any()) } @@ -223,15 +228,153 @@ class LightningNodeServiceTest : BaseUnitTest() { capturedHandler?.invoke(event) testScheduler.advanceUntilIdle() - val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val notificationManager = context.notificationManager val shadows = Shadows.shadowOf(notificationManager) val paymentNotification = shadows.allNotifications.find { it.extras.getString(Notification.EXTRA_TITLE) == context.getString(R.string.notification__received__title) } - assertNotNull("Payment notification should be present", paymentNotification) + assertNotNull(paymentNotification, "Payment notification should be present") - val body = paymentNotification?.extras?.getString(Notification.EXTRA_TEXT) + val body = paymentNotification.extras?.getString(Notification.EXTRA_TEXT) assertEquals($$"Received ₿ 100 ($0.10)", body) } + + @Test + fun `pending payment success in background shows notification`() = test { + val sentTitle = context.getString(R.string.wallet__toast_payment_sent_title) + val sentBody = context.getString(R.string.wallet__toast_payment_sent_description) + whenever(notifyPendingPaymentResolvedHandler.invoke(any())).thenReturn( + Result.success( + NotifyPendingPaymentResolved.Result.ShowNotification( + NotificationDetails(title = sentTitle, body = sentBody) + ) + ) + ) + + val controller = Robolectric.buildService(LightningNodeService::class.java) + controller.create().startCommand(0, 0) + testScheduler.advanceUntilIdle() + + val event = Event.PaymentSuccessful( + paymentId = "payment_id", + paymentHash = "test_hash", + paymentPreimage = "preimage", + feePaidMsat = 10uL, + ) + + capturedHandler?.invoke(event) + testScheduler.advanceUntilIdle() + + val notificationManager = context.notificationManager + val shadows = Shadows.shadowOf(notificationManager) + + val notification = shadows.allNotifications.find { + it.extras.getString(Notification.EXTRA_TITLE) == sentTitle + } + assertNotNull(notification, "Pending payment success notification should be present") + assertEquals(sentBody, notification?.extras?.getString(Notification.EXTRA_TEXT)) + } + + @Test + fun `pending payment failure in background shows notification`() = test { + val failedTitle = context.getString(R.string.wallet__toast_payment_failed_title) + val failedBody = context.getString(R.string.wallet__toast_payment_failed_description) + whenever(notifyPendingPaymentResolvedHandler.invoke(any())).thenReturn( + Result.success( + NotifyPendingPaymentResolved.Result.ShowNotification( + NotificationDetails(title = failedTitle, body = failedBody) + ) + ) + ) + + val controller = Robolectric.buildService(LightningNodeService::class.java) + controller.create().startCommand(0, 0) + testScheduler.advanceUntilIdle() + + val event = Event.PaymentFailed( + paymentId = "payment_id", + paymentHash = "test_hash", + reason = null, + ) + + capturedHandler?.invoke(event) + testScheduler.advanceUntilIdle() + + val notificationManager = context.notificationManager + val shadows = Shadows.shadowOf(notificationManager) + + val notification = shadows.allNotifications.find { + it.extras.getString(Notification.EXTRA_TITLE) == failedTitle + } + assertNotNull(notification, "Pending payment failure notification should be present") + assertEquals(failedBody, notification?.extras?.getString(Notification.EXTRA_TEXT)) + } + + @Test + fun `pending payment success in foreground skips notification`() = test { + val mockActivity: Activity = mock() + App.currentActivity?.onActivityStarted(mockActivity) + + val sentTitle = context.getString(R.string.wallet__toast_payment_sent_title) + whenever(notifyPendingPaymentResolvedHandler.invoke(any())).thenReturn( + Result.success( + NotifyPendingPaymentResolved.Result.ShowNotification( + NotificationDetails(title = sentTitle, body = "body") + ) + ) + ) + + val controller = Robolectric.buildService(LightningNodeService::class.java) + controller.create().startCommand(0, 0) + testScheduler.advanceUntilIdle() + + val event = Event.PaymentSuccessful( + paymentId = "payment_id", + paymentHash = "test_hash", + paymentPreimage = "preimage", + feePaidMsat = 10uL, + ) + + capturedHandler?.invoke(event) + testScheduler.advanceUntilIdle() + + val notificationManager = context.notificationManager + val shadows = Shadows.shadowOf(notificationManager) + + val notification = shadows.allNotifications.find { + it.extras.getString(Notification.EXTRA_TITLE) == sentTitle + } + assertNull(notification, "Pending payment notification should NOT be present in foreground") + } + + @Test + fun `non-pending payment skips notification`() = test { + whenever(notifyPendingPaymentResolvedHandler.invoke(any())).thenReturn( + Result.success(NotifyPendingPaymentResolved.Result.Skip) + ) + + val controller = Robolectric.buildService(LightningNodeService::class.java) + controller.create().startCommand(0, 0) + testScheduler.advanceUntilIdle() + + val event = Event.PaymentSuccessful( + paymentId = "payment_id", + paymentHash = "non_pending_hash", + paymentPreimage = "preimage", + feePaidMsat = 10uL, + ) + + capturedHandler?.invoke(event) + testScheduler.advanceUntilIdle() + + val notificationManager = context.notificationManager + val shadows = Shadows.shadowOf(notificationManager) + + val sentTitle = context.getString(R.string.wallet__toast_payment_sent_title) + val notification = shadows.allNotifications.find { + it.extras.getString(Notification.EXTRA_TITLE) == sentTitle + } + assertNull(notification, "Non-pending payment should NOT trigger notification") + } } diff --git a/app/src/test/java/to/bitkit/domain/commands/NotifyPendingPaymentResolvedHandlerTest.kt b/app/src/test/java/to/bitkit/domain/commands/NotifyPendingPaymentResolvedHandlerTest.kt new file mode 100644 index 000000000..98db5854e --- /dev/null +++ b/app/src/test/java/to/bitkit/domain/commands/NotifyPendingPaymentResolvedHandlerTest.kt @@ -0,0 +1,91 @@ +package to.bitkit.domain.commands + +import android.content.Context +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.R +import to.bitkit.repositories.PendingPaymentRepo +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class NotifyPendingPaymentResolvedHandlerTest : BaseUnitTest() { + + private val context: Context = mock() + private val pendingPaymentRepo: PendingPaymentRepo = mock() + + private lateinit var sut: NotifyPendingPaymentResolvedHandler + + @Before + fun setUp() { + whenever(context.getString(R.string.wallet__toast_payment_sent_title)) + .thenReturn("Payment Sent") + whenever(context.getString(R.string.wallet__toast_payment_sent_description)) + .thenReturn("Your pending payment was completed successfully.") + whenever(context.getString(R.string.wallet__toast_payment_failed_title)) + .thenReturn("Payment Failed") + whenever(context.getString(R.string.wallet__toast_payment_failed_description)) + .thenReturn("Your instant payment failed. Please try again.") + + sut = NotifyPendingPaymentResolvedHandler( + context = context, + ioDispatcher = testDispatcher, + pendingPaymentRepo = pendingPaymentRepo, + ) + } + + @Test + fun `success command returns ShowNotification when pending`() = test { + whenever(pendingPaymentRepo.isPending(any())).thenReturn(true) + val command = NotifyPendingPaymentResolved.Command.Success(paymentHash = "hash123") + + val result = sut(command) + + assertTrue(result.isSuccess) + val paymentResult = result.getOrThrow() + assertTrue(paymentResult is NotifyPendingPaymentResolved.Result.ShowNotification) + assertEquals("Payment Sent", paymentResult.notification.title) + assertEquals("Your pending payment was completed successfully.", paymentResult.notification.body) + } + + @Test + fun `failure command returns ShowNotification when pending`() = test { + whenever(pendingPaymentRepo.isPending(any())).thenReturn(true) + val command = NotifyPendingPaymentResolved.Command.Failure(paymentHash = "hash456") + + val result = sut(command) + + assertTrue(result.isSuccess) + val paymentResult = result.getOrThrow() + assertTrue(paymentResult is NotifyPendingPaymentResolved.Result.ShowNotification) + assertEquals("Payment Failed", paymentResult.notification.title) + assertEquals("Your instant payment failed. Please try again.", paymentResult.notification.body) + } + + @Test + fun `success command returns Skip when not pending`() = test { + whenever(pendingPaymentRepo.isPending(any())).thenReturn(false) + val command = NotifyPendingPaymentResolved.Command.Success(paymentHash = "hash789") + + val result = sut(command) + + assertTrue(result.isSuccess) + val paymentResult = result.getOrThrow() + assertTrue(paymentResult is NotifyPendingPaymentResolved.Result.Skip) + } + + @Test + fun `failure command returns Skip when not pending`() = test { + whenever(pendingPaymentRepo.isPending(any())).thenReturn(false) + val command = NotifyPendingPaymentResolved.Command.Failure(paymentHash = "hash000") + + val result = sut(command) + + assertTrue(result.isSuccess) + val paymentResult = result.getOrThrow() + assertTrue(paymentResult is NotifyPendingPaymentResolved.Result.Skip) + } +} diff --git a/app/src/test/java/to/bitkit/models/CurrencyTest.kt b/app/src/test/java/to/bitkit/models/CurrencyTest.kt index a79ced622..71f88e25d 100644 --- a/app/src/test/java/to/bitkit/models/CurrencyTest.kt +++ b/app/src/test/java/to/bitkit/models/CurrencyTest.kt @@ -1,31 +1,33 @@ package to.bitkit.models -import org.junit.Assert.assertEquals import org.junit.Test import java.math.BigDecimal import java.util.Locale +import kotlin.test.assertEquals class CurrencyTest { + private val locale = Locale.US + @Test fun `formatToModernDisplay uses space grouping`() { val sats = 123_456_789L - val formatted = sats.formatToModernDisplay(Locale.US) + val formatted = sats.formatToModernDisplay(locale) assertEquals("123 456 789", formatted) } @Test fun `formatToModernDisplay handles zero`() { - val formatted = 0L.formatToModernDisplay(Locale.US) + val formatted = 0L.formatToModernDisplay(locale) assertEquals("0", formatted) } @Test fun `formatToClassicDisplay always shows eight decimals`() { - val formatted = 0L.formatToClassicDisplay(Locale.US) + val formatted = 0L.formatToClassicDisplay(locale) assertEquals("0.00000000", formatted) } @@ -34,7 +36,7 @@ class CurrencyTest { fun `formatToClassicDisplay converts sats to btc`() { val sats = 12_345L // 0.00012345 BTC - val formatted = sats.formatToClassicDisplay(Locale.US) + val formatted = sats.formatToClassicDisplay(locale) assertEquals("0.00012345", formatted) } @@ -43,7 +45,7 @@ class CurrencyTest { fun `formatCurrencyWithSymbol places USD symbol before amount`() { val value = BigDecimal("10.50") - val formatted = value.formatCurrencyWithSymbol("USD") + val formatted = value.formatCurrencyWithSymbol("USD", locale = locale) assertEquals("$10.50", formatted) } @@ -52,7 +54,7 @@ class CurrencyTest { fun `formatCurrencyWithSymbol places GBP symbol before amount`() { val value = BigDecimal("10.50") - val formatted = value.formatCurrencyWithSymbol("GBP") + val formatted = value.formatCurrencyWithSymbol("GBP", locale = locale) assertEquals("£10.50", formatted) } @@ -109,6 +111,7 @@ class CurrencyTest { val formatted = value.formatCurrencyWithSymbol( currencyCode = "USD", withSpace = true, + locale = locale, ) assertEquals("$ 10.50", formatted) @@ -150,7 +153,7 @@ class CurrencyTest { fun `formatCurrencyWithSymbol formats large amounts with grouping`() { val value = BigDecimal("1234567.89") - val formatted = value.formatCurrencyWithSymbol("USD") + val formatted = value.formatCurrencyWithSymbol("USD", locale = locale) assertEquals("$1,234,567.89", formatted) } diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index d27e5f361..6666d0be8 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -57,6 +57,7 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +@Suppress("LargeClass") class LightningRepoTest : BaseUnitTest() { private lateinit var sut: LightningRepo diff --git a/app/src/test/java/to/bitkit/repositories/PendingPaymentRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PendingPaymentRepoTest.kt new file mode 100644 index 000000000..301c83ce7 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PendingPaymentRepoTest.kt @@ -0,0 +1,106 @@ +package to.bitkit.repositories + +import app.cash.turbine.test +import org.junit.Before +import org.junit.Test +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class PendingPaymentRepoTest : BaseUnitTest() { + + private lateinit var sut: PendingPaymentRepo + + @Before + fun setUp() { + sut = PendingPaymentRepo() + } + + @Test + fun `track adds hash to pending set`() { + sut.track("hash1") + assertTrue(sut.isPending("hash1")) + } + + @Test + fun `track is idempotent for duplicate hash`() { + sut.track("hash1") + sut.track("hash1") + assertEquals(1, sut.state.value.pendingPayments.size) + } + + @Test + fun `isPending returns true after track`() { + sut.track("hash1") + assertTrue(sut.isPending("hash1")) + } + + @Test + fun `isPending returns false for untracked hash`() { + assertFalse(sut.isPending("unknown")) + } + + @Test + fun `resolve removes tracked hash from pending set`() = test { + sut.track("hash1") + sut.resolve(PendingPaymentResolution.Success("hash1")) + assertFalse(sut.isPending("hash1")) + } + + @Test + fun `resolve on untracked hash does not change state`() = test { + sut.resolve(PendingPaymentResolution.Success("unknown")) + assertTrue(sut.state.value.pendingPayments.isEmpty()) + } + + @Test + fun `resolve emits Success on resolution flow`() = test { + sut.track("hash1") + sut.resolution.test { + sut.resolve(PendingPaymentResolution.Success("hash1")) + val item = awaitItem() + assertIs(item) + assertEquals("hash1", item.paymentHash) + } + } + + @Test + fun `resolve emits Failure on resolution flow`() = test { + sut.track("hash1") + sut.resolution.test { + sut.resolve(PendingPaymentResolution.Failure("hash1")) + val item = awaitItem() + assertIs(item) + assertEquals("hash1", item.paymentHash) + } + } + + @Test + fun `setActiveHash and isActive returns true for active hash`() { + sut.setActiveHash("hash1") + assertTrue(sut.isActive("hash1")) + } + + @Test + fun `isActive returns false for non-active hash`() { + sut.setActiveHash("hash1") + assertFalse(sut.isActive("hash2")) + } + + @Test + fun `setActiveHash null clears active hash`() { + sut.setActiveHash("hash1") + sut.setActiveHash(null) + assertFalse(sut.isActive("hash1")) + } + + @Test + fun `resolve does not affect activeHash`() = test { + sut.track("hash1") + sut.setActiveHash("hash1") + sut.resolve(PendingPaymentResolution.Success("hash1")) + assertEquals("hash1", sut.state.value.activeHash) + } +} diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt new file mode 100644 index 000000000..38ca3ec21 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt @@ -0,0 +1,143 @@ +package to.bitkit.ui.screens.wallets.send + +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.LightningActivity +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.PendingPaymentRepo +import to.bitkit.repositories.PendingPaymentResolution +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +@OptIn(ExperimentalCoroutinesApi::class) +class SendPendingViewModelTest : BaseUnitTest() { + + private val pendingPaymentRepo = PendingPaymentRepo() + private val activityRepo: ActivityRepo = mock() + + private val hash = "test_payment_hash" + private val amount = 5000L + + private lateinit var sut: SendPendingViewModel + + @Before + fun setUp() { + whenever { activityRepo.findActivityByPaymentId(any(), any(), any(), any()) }.thenReturn( + Result.failure(Exception("not found")) + ) + sut = createViewModel() + } + + @Test + fun `init sets amount in uiState`() = test { + sut.init(hash, amount) + advanceUntilIdle() + + assertEquals(amount, sut.uiState.value.amount) + } + + @Test + fun `init sets activeHash on repo`() = test { + sut.init(hash, amount) + advanceUntilIdle() + + assertEquals(true, pendingPaymentRepo.isActive(hash)) + } + + @Test + fun `init is idempotent`() = test { + sut.init(hash, amount) + sut.init(hash, 9999L) + advanceUntilIdle() + + assertEquals(amount, sut.uiState.value.amount) + } + + @Test + fun `findActivity sets activityId`() = test { + val activityId = "activity_id_123" + val activityV1 = mock { on { id } doReturn activityId } + val activity = mock { on { v1 } doReturn activityV1 } + whenever(activityRepo.findActivityByPaymentId(any(), any(), any(), any())).thenReturn(Result.success(activity)) + + sut.init(hash, amount) + advanceUntilIdle() + + assertEquals(activityId, sut.uiState.value.activityId) + } + + @Test + fun `findActivity failure leaves activityId null`() = test { + sut.init(hash, amount) + advanceUntilIdle() + + assertNull(sut.uiState.value.activityId) + } + + @Test + fun `observeResolution Success updates uiState`() = test { + sut.init(hash, amount) + advanceUntilIdle() + + pendingPaymentRepo.track(hash) + pendingPaymentRepo.resolve(PendingPaymentResolution.Success(hash)) + advanceUntilIdle() + + val resolution = sut.uiState.value.resolution + assertIs(resolution) + assertEquals(hash, resolution.paymentHash) + } + + @Test + fun `observeResolution Failure updates uiState`() = test { + sut.init(hash, amount) + advanceUntilIdle() + + pendingPaymentRepo.track(hash) + pendingPaymentRepo.resolve(PendingPaymentResolution.Failure(hash)) + advanceUntilIdle() + + val resolution = sut.uiState.value.resolution + assertIs(resolution) + } + + @Test + fun `observeResolution ignores other hashes`() = test { + sut.init(hash, amount) + advanceUntilIdle() + + pendingPaymentRepo.track("other_hash") + pendingPaymentRepo.resolve(PendingPaymentResolution.Success("other_hash")) + advanceUntilIdle() + + assertNull(sut.uiState.value.resolution) + } + + @Test + fun `onResolutionHandled clears resolution`() = test { + sut.init(hash, amount) + advanceUntilIdle() + + pendingPaymentRepo.track(hash) + pendingPaymentRepo.resolve(PendingPaymentResolution.Success(hash)) + advanceUntilIdle() + + sut.onResolutionHandled() + + assertNull(sut.uiState.value.resolution) + } + + private fun createViewModel() = SendPendingViewModel( + pendingPaymentRepo = pendingPaymentRepo, + activityRepo = activityRepo, + ) +}