Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
53c2175
chore: update AI rules
ovitrif Mar 5, 2026
e14faed
feat: add send payment pending UI
ovitrif Mar 5, 2026
28b25b8
fix: clear pending screen from backstack
ovitrif Mar 5, 2026
fd280e8
fix: suppress payment toasts on pending sheet
ovitrif Mar 5, 2026
313a936
refactor: extract pending viewmodel to own file
ovitrif Mar 5, 2026
db00048
fix: currency tests deterministic locale
ovitrif Mar 6, 2026
3827bc3
chore: log payment resolution receive
ovitrif Mar 6, 2026
8215e5b
fix: review comments
ovitrif Mar 6, 2026
7466e76
fix: show toast for parallel pending payments
ovitrif Mar 6, 2026
27736fe
chore: review fixes
ovitrif Mar 6, 2026
cb946d6
fix: try again if send via scanner
ovitrif Mar 6, 2026
002f554
feat: add pending payment query
ovitrif Mar 6, 2026
06773dd
feat: add pending resolution command
ovitrif Mar 6, 2026
734c366
feat: notify pending payment resolution
ovitrif Mar 6, 2026
b7055ee
refactor: extract pending payment repo
ovitrif Mar 6, 2026
717b8a4
refactor: use new guard conditions for watchUntil
ovitrif Mar 6, 2026
f34f18c
test: pending payments logic
ovitrif Mar 6, 2026
1b72226
fix: non-service notifications icon
ovitrif Mar 6, 2026
af2fb39
fix: pending payment error notification text
ovitrif Mar 6, 2026
6343887
refactor: shared notification model and repo state
ovitrif Mar 6, 2026
78c8a51
fix: remove pending error sheet message
ovitrif Mar 6, 2026
59a688d
refactor: remove redundant resolution wrapper
ovitrif Mar 6, 2026
c50fea5
refactor: simplify
ovitrif Mar 6, 2026
1b71bb5
refactor: use context ext prop
ovitrif Mar 7, 2026
5f96938
chore: update /pr ai command description
ovitrif Mar 7, 2026
27a378b
fix: active hash clearing bug and review issues
ovitrif Mar 7, 2026
becd509
refactor: extract PendingPaymentNotification
ovitrif Mar 7, 2026
8de49b3
refactor: cleanup vm test
ovitrif Mar 7, 2026
be29098
fix: resolve pr review comments
ovitrif Mar 7, 2026
6ceb732
chore: update ai rule for docs
ovitrif Mar 7, 2026
c0781d2
Merge pull request #828 from synonymdev/feat/send-pending-ui-notif
jvsena42 Mar 9, 2026
a87fc61
Merge branch 'master' into feat/send-pending-ui
ovitrif Mar 9, 2026
625b0f0
fix: make QuickPayData stable
ovitrif Mar 9, 2026
818311a
chore: remove unused string
ovitrif Mar 9, 2026
45ca017
refactor: reformat node service tests
ovitrif Mar 9, 2026
bf677fc
fix: use emit
ovitrif Mar 9, 2026
a03b4b0
fix: add brand color to push notifications
ovitrif Mar 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/commands/pr.md
Original file line number Diff line number Diff line change
@@ -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
---
Expand Down
37 changes: 26 additions & 11 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -165,10 +165,12 @@ suspend fun getData(): Result<Data> = 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
Expand All @@ -179,6 +181,8 @@ suspend fun getData(): Result<Data> = 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<T>` in try-catch
Expand All @@ -194,7 +198,7 @@ suspend fun getData(): Result<Data> = 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 =`
Expand All @@ -203,19 +207,30 @@ suspend fun getData(): Result<Data> = 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<T>` 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<T>` 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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -51,6 +53,9 @@ class LightningNodeService : Service() {
@Inject
lateinit var notifyPaymentReceivedHandler: NotifyPaymentReceivedHandler

@Inject
lateinit var notifyPendingPaymentResolvedHandler: NotifyPendingPaymentResolvedHandler

@Inject
lateinit var cacheStore: CacheStore

Expand All @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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<NotifyPendingPaymentResolved.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)
}
}
38 changes: 23 additions & 15 deletions app/src/main/java/to/bitkit/ext/Flows.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 <T, R> Flow<T>.watchUntil(
timeout: Duration = INFINITE,
crossinline predicate: suspend (T) -> WatchResult<R>,
): R {
val result = CompletableDeferred<R>()
): R? {
return withTimeoutOrNull(timeout) {
val result = CompletableDeferred<R>()

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<T> {
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/java/to/bitkit/models/Currency.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""

Expand Down
3 changes: 2 additions & 1 deletion app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down Expand Up @@ -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
}
}

Expand Down
64 changes: 64 additions & 0 deletions app/src/main/java/to/bitkit/repositories/PendingPaymentRepo.kt
Original file line number Diff line number Diff line change
@@ -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<PendingPaymentResolution>(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<String> = 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),
)
}
3 changes: 2 additions & 1 deletion app/src/main/java/to/bitkit/ui/Notifications.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ class ExternalNodeViewModel @Inject constructor(

else -> WatchResult.Continue()
}
}.let {
checkNotNull(it) { "Timeout in awaitChannelPendingEvent for userChannelId='$userChannelId'" }
}
}
}
Expand Down
Loading
Loading