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 daf71dcfd..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 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 @@ -196,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 =` @@ -205,12 +207,13 @@ 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 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 environments +- 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 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 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 @@ -219,12 +222,13 @@ suspend fun getData(): Result = withContext(Dispatchers.IO) { - 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 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 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/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index e9324dde0..3c9ecdc05 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -76,7 +76,6 @@ import to.bitkit.utils.AppError import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError import java.io.File -import java.util.Collections import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference @@ -109,10 +108,6 @@ class LightningRepo @Inject constructor( private val _nodeEvents = MutableSharedFlow(extraBufferCapacity = 64) val nodeEvents = _nodeEvents.asSharedFlow() - private val pendingPayments = Collections.synchronizedSet(mutableSetOf()) - private val _pendingPaymentResolution = MutableSharedFlow(extraBufferCapacity = 1) - val pendingPaymentResolution = _pendingPaymentResolution.asSharedFlow() - private val scope = CoroutineScope(bgDispatcher + SupervisorJob()) private val _eventHandlers = ConcurrentHashMap.newKeySet() @@ -126,7 +121,6 @@ class LightningRepo @Inject constructor( private val syncRetryJob = AtomicReference(null) private val lifecycleMutex = Mutex() private val isChangingAddressType = AtomicBoolean(false) - private val _activePendingPaymentHash = AtomicReference(null) init { observeConnectivityForSyncRetry() @@ -1383,27 +1377,10 @@ 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_LIGHTNING_TIMEOUT = 10.seconds - } - - fun trackPendingPayment(paymentHash: String) = pendingPayments.add(paymentHash) - - fun setActivePendingPaymentHash(hash: String?) = run { _activePendingPaymentHash.set(hash) } - - fun isActivePendingPayment(hash: String): Boolean = _activePendingPaymentHash.get() == hash - - fun resolvePendingPayment(resolution: PendingPaymentResolution): Boolean { - val hash = when (resolution) { - is PendingPaymentResolution.Success -> resolution.paymentHash - is PendingPaymentResolution.Failure -> resolution.paymentHash - } - if (!pendingPayments.remove(hash)) return false - _pendingPaymentResolution.tryEmit(resolution) - return true + val SEND_LN_TIMEOUT = 10.seconds } } -class PaymentPendingException(val paymentHash: String) : AppError("Payment pending") class RecoveryModeError : AppError("App in recovery mode, skipping node start") class NodeSetupError : AppError("Unknown node setup error") class NodeStopTimeoutError : AppError("Timeout waiting for node to stop") @@ -1411,13 +1388,6 @@ class NodeRunTimeoutError(opName: String) : AppError("Timeout waiting for node t class GetPaymentsError : AppError("It wasn't possible get the payments") class SyncUnhealthyError : AppError("Wallet sync failed before send") -sealed interface PendingPaymentResolution { - val paymentHash: String - - data class Success(override val paymentHash: String) : PendingPaymentResolution - data class Failure(override val paymentHash: String, val reason: String?) : PendingPaymentResolution -} - data class LightningState( val nodeId: String = "", val nodeStatus: NodeStatus? = null, 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..fd03d01fa --- /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) + + fun resolve(resolution: PendingPaymentResolution) { + _state.update { it.copy(pendingPayments = it.pendingPayments - resolution.paymentHash) } + _resolution.tryEmit(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..a7ba31bd9 100644 --- a/app/src/main/java/to/bitkit/ui/Notifications.kt +++ b/app/src/main/java/to/bitkit/ui/Notifications.kt @@ -52,7 +52,7 @@ 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) .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/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 index 79114417b..ead96da93 100644 --- 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 @@ -27,7 +27,7 @@ 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.models.NewTransactionSheetDetails +import to.bitkit.repositories.PendingPaymentResolution import to.bitkit.ui.components.BalanceHeaderView import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BottomSheetPreview @@ -36,19 +36,17 @@ 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.screens.wallets.send.SendPendingUiState.Resolution 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 -import to.bitkit.utils.Logger @Composable fun SendPendingScreen( paymentHash: String, amount: Long, - onPaymentSuccess: (NewTransactionSheetDetails) -> Unit, - onPaymentError: (String) -> Unit, + onPaymentSuccess: (String) -> Unit, + onPaymentError: () -> Unit, onClose: () -> Unit, onViewDetails: (String) -> Unit, viewModel: SendPendingViewModel, @@ -59,12 +57,10 @@ fun SendPendingScreen( uiState.resolution?.let { resolution -> LaunchedEffect(resolution) { - runCatching { - when (resolution) { - is Resolution.Success -> onPaymentSuccess(resolution.details) - is Resolution.Error -> onPaymentError(resolution.message) - } - }.onFailure { Logger.error("Failed handling payment resolution", it) } + when (resolution) { + is PendingPaymentResolution.Success -> onPaymentSuccess(resolution.paymentHash) + is PendingPaymentResolution.Failure -> onPaymentError() + } viewModel.onResolutionHandled() } } 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 index fcad99589..98643c683 100644 --- 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 @@ -1,34 +1,26 @@ package to.bitkit.ui.screens.wallets.send -import android.content.Context 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 dagger.hilt.android.qualifiers.ApplicationContext 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.R import to.bitkit.ext.rawId -import to.bitkit.models.NewTransactionSheetDetails -import to.bitkit.models.NewTransactionSheetDirection -import to.bitkit.models.NewTransactionSheetType import to.bitkit.repositories.ActivityRepo -import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.PendingPaymentResolution -import to.bitkit.ui.screens.wallets.send.SendPendingUiState.Resolution import to.bitkit.utils.Logger import javax.inject.Inject @HiltViewModel class SendPendingViewModel @Inject constructor( - private val lightningRepo: LightningRepo, + private val pendingPaymentRepo: PendingPaymentRepo, private val activityRepo: ActivityRepo, - @ApplicationContext private val context: Context, ) : ViewModel() { companion object { @@ -43,14 +35,14 @@ class SendPendingViewModel @Inject constructor( fun init(paymentHash: String, amount: Long) { if (isInitialized) return isInitialized = true - lightningRepo.setActivePendingPaymentHash(paymentHash) + pendingPaymentRepo.setActiveHash(paymentHash) _uiState.update { it.copy(amount = amount) } findActivity(paymentHash) - observeResolution(paymentHash, amount) + observeResolution(paymentHash) } override fun onCleared() { - lightningRepo.setActivePendingPaymentHash(null) + pendingPaymentRepo.setActiveHash(null) } fun onResolutionHandled() = _uiState.update { it.copy(resolution = null) } @@ -68,33 +60,16 @@ class SendPendingViewModel @Inject constructor( } } - private fun observeResolution(paymentHash: String, amount: Long) { + private fun observeResolution(paymentHash: String) { viewModelScope.launch { - lightningRepo.pendingPaymentResolution + 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 = when (resolution) { - is PendingPaymentResolution.Success -> Resolution.Success( - NewTransactionSheetDetails( - type = NewTransactionSheetType.LIGHTNING, - direction = NewTransactionSheetDirection.SENT, - paymentHashOrTxId = resolution.paymentHash, - sats = amount, - ) - ) - - is PendingPaymentResolution.Failure -> Resolution.Error( - resolution.reason ?: context.getString(R.string.wallet__toast_payment_failed_title) - ) - } - ) - } + _uiState.update { it.copy(resolution = resolution) } } } } @@ -103,10 +78,5 @@ class SendPendingViewModel @Inject constructor( data class SendPendingUiState( val amount: Long = 0L, val activityId: String? = null, - val resolution: Resolution? = null, -) { - sealed interface Resolution { - data class Success(val details: NewTransactionSheetDetails) : Resolution - data class Error(val message: String) : Resolution - } -} + val resolution: PendingPaymentResolution? = null, +) 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 b9355bb0c..5af5e4449 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -259,7 +259,7 @@ fun SendSheet( SendQuickPayScreen( quickPayData = requireNotNull(quickPayData), onPaymentComplete = { paymentHash, amountWithFee -> - appViewModel.handlePaymentSuccess( + appViewModel.onSendSuccess( NewTransactionSheetDetails( type = NewTransactionSheetType.LIGHTNING, direction = NewTransactionSheetDirection.SENT, @@ -269,7 +269,6 @@ fun SendSheet( ) }, onPaymentPending = { paymentHash, amount -> - appViewModel.trackPendingPayment(paymentHash) navController.navigate(SendRoute.Pending(paymentHash, amount)) { popUpTo(startDestination) { inclusive = true } } @@ -284,11 +283,18 @@ fun SendSheet( SendPendingScreen( paymentHash = route.paymentHash, amount = route.amount, - onPaymentSuccess = { details -> - appViewModel.handlePaymentSuccess(details) + onPaymentSuccess = { paymentHash -> + appViewModel.onSendSuccess( + NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.SENT, + paymentHashOrTxId = paymentHash, + sats = route.amount, + ), + ) }, - onPaymentError = { errorMessage -> - navController.navigate(SendRoute.Error(errorMessage)) { + onPaymentError = { + navController.navigate(SendRoute.Error()) { popUpTo { inclusive = true } } }, @@ -306,7 +312,7 @@ fun SendSheet( composableWithDefaultTransitions { val route = it.toRoute() SendErrorScreen( - errorMessage = route.errorMessage, + message = route.message, onRetry = { navController.navigate(SendRoute.Recipient) { popUpTo(navController.graph.id) { inclusive = true } @@ -377,5 +383,5 @@ sealed interface SendRoute { data class Pending(val paymentHash: String, val amount: Long) : SendRoute @Serializable - data class Error(val errorMessage: String) : SendRoute + data class Error(val message: String? = null) : SendRoute } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 44de7c315..3bb643a77 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -104,6 +104,8 @@ 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 @@ -118,6 +120,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 @@ -145,6 +148,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, @@ -430,6 +434,7 @@ class AppViewModel @Inject constructor( lightningRepo.pruneEmptyAddressTypesAfterRestore() walletRepo.debounceSyncByEvent() } + !isShowingLoading && !needsPostMigrationSync && !isCompletingMigration -> walletRepo.debounceSyncByEvent() else -> Unit } @@ -588,11 +593,9 @@ class AppViewModel @Inject constructor( private suspend fun handlePaymentFailed(event: Event.PaymentFailed) { event.paymentHash?.let { paymentHash -> activityRepo.handlePaymentEvent(paymentHash) - val resolved = lightningRepo.resolvePendingPayment( - PendingPaymentResolution.Failure(paymentHash, event.reason.toUserMessage(context)) - ) - if (resolved) { - if (_currentSheet.value !is Sheet.Send || !lightningRepo.isActivePendingPayment(paymentHash)) { + if (pendingPaymentRepo.isPending(paymentHash)) { + pendingPaymentRepo.resolve(PendingPaymentResolution.Failure(paymentHash)) + if (_currentSheet.value !is Sheet.Send || !pendingPaymentRepo.isActive(paymentHash)) { notifyPendingPaymentFailed() } return @@ -611,9 +614,9 @@ class AppViewModel @Inject constructor( private suspend fun handlePaymentSuccessful(event: Event.PaymentSuccessful) { event.paymentHash.let { paymentHash -> activityRepo.handlePaymentEvent(paymentHash) - val resolved = lightningRepo.resolvePendingPayment(PendingPaymentResolution.Success(paymentHash)) - if (resolved) { - if (_currentSheet.value !is Sheet.Send || !lightningRepo.isActivePendingPayment(paymentHash)) { + if (pendingPaymentRepo.isPending(paymentHash)) { + pendingPaymentRepo.resolve(PendingPaymentResolution.Success(paymentHash)) + if (_currentSheet.value !is Sheet.Send || !pendingPaymentRepo.isActive(paymentHash)) { notifyPendingPaymentSucceeded() } return @@ -693,19 +696,23 @@ class AppViewModel @Inject constructor( ) } - private fun notifyPendingPaymentSucceeded() = toast( - type = Toast.ToastType.LIGHTNING, - title = context.getString(R.string.wallet__toast_payment_sent_title), - description = context.getString(R.string.wallet__toast_payment_sent_description), - testTag = "PendingPaymentSucceededToast", - ) + private fun notifyPendingPaymentSucceeded() = PendingPaymentNotification.success(context).let { + toast( + type = Toast.ToastType.LIGHTNING, + title = it.title, + description = it.body, + testTag = "PendingPaymentSucceededToast", + ) + } - private fun notifyPendingPaymentFailed() = toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.wallet__toast_payment_failed_title), - description = context.getString(R.string.wallet__toast_payment_failed_description), - testTag = "PendingPaymentFailedToast", - ) + 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, @@ -723,7 +730,7 @@ class AppViewModel @Inject constructor( txType = PaymentType.SENT, retry = true ).onSuccess { activity -> - handlePaymentSuccess( + onSendSuccess( NewTransactionSheetDetails( type = NewTransactionSheetType.LIGHTNING, direction = NewTransactionSheetDirection.SENT, @@ -731,8 +738,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) } } @@ -1670,7 +1677,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, @@ -1721,7 +1728,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, @@ -1729,19 +1736,19 @@ class AppViewModel @Inject constructor( sats = paymentAmount.toLong(), // TODO Add fee when available ), ) - }.onFailure { e -> - if (e is PaymentPendingException) { + }.onFailure { + if (it is PaymentPendingException) { Logger.info("Lightning payment pending", context = TAG) - lightningRepo.trackPendingPayment(e.paymentHash) - setSendEffect(SendEffect.NavigateToPending(e.paymentHash, paymentAmount.toLong())) + 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() } } @@ -1867,26 +1874,12 @@ class AppViewModel @Inject constructor( ): Result { return lightningRepo.payInvoice(bolt11 = bolt11, sats = amount).onSuccess { hash -> // Wait until matching payment event is received (with timeout for hold invoices) - val result = lightningRepo.nodeEvents.watchUntil( - timeout = LightningRepo.SEND_LIGHTNING_TIMEOUT, - ) { 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() - } - } + 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() } @@ -1906,8 +1899,6 @@ class AppViewModel @Inject constructor( fun resetQuickPay() = _quickPayData.update { null } - fun trackPendingPayment(paymentHash: String) = lightningRepo.trackPendingPayment(paymentHash) - fun navigateToActivity(activityRawId: String) { viewModelScope.launch { hideSheet() @@ -2286,7 +2277,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) @@ -2295,7 +2286,7 @@ class AppViewModel @Inject constructor( } _successSendUiState.update { details } - setSendEffect(SendEffect.PaymentSuccess(details)) + setSendEffect(SendEffect.PaymentSuccess) } fun handleDeeplinkIntent(intent: Intent) { @@ -2438,7 +2429,7 @@ 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() } diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index 5dc0ddf7a..feda0ed8e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -16,6 +16,8 @@ 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 @@ -23,6 +25,7 @@ import javax.inject.Inject class QuickPayViewModel @Inject constructor( @ApplicationContext private val context: Context, private val lightningRepo: LightningRepo, + private val pendingPaymentRepo: PendingPaymentRepo, ) : ViewModel() { companion object { @@ -34,9 +37,9 @@ class QuickPayViewModel @Inject constructor( 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 @@ -51,7 +54,7 @@ class QuickPayViewModel @Inject constructor( } return@launch } - invoice.bolt11 to quickPayData.sats + invoice.bolt11 to data.sats } } @@ -69,6 +72,7 @@ class QuickPayViewModel @Inject constructor( }.onFailure { error -> if (error is PaymentPendingException) { Logger.info("QuickPay lightning payment pending", context = TAG) + pendingPaymentRepo.track(error.paymentHash) _uiState.update { it.copy( result = QuickPayResult.Pending( @@ -99,26 +103,12 @@ class QuickPayViewModel @Inject constructor( .getOrDefault("") // Wait until matching payment event is received (with timeout for hold invoices) - val result = lightningRepo.nodeEvents.watchUntil( - timeout = LightningRepo.SEND_LIGHTNING_TIMEOUT, - ) { 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() - } - } + 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() } diff --git a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt index 918e31ac8..d26e8f248 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,9 @@ 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 kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull import org.junit.Before import org.junit.Rule import org.junit.Test @@ -47,6 +46,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 @@ -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,157 @@ 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) 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/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..9b3f4a1e8 --- /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`() { + sut.track("hash1") + sut.resolve(PendingPaymentResolution.Success("hash1")) + assertFalse(sut.isPending("hash1")) + } + + @Test + fun `resolve on untracked hash does not change state`() { + 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`() { + 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, + ) +}