Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 12 additions & 8 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 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 Down Expand Up @@ -196,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 @@ -205,12 +207,13 @@ 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 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<T>` and use it in ViewModels
- 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
Expand All @@ -219,12 +222,13 @@ suspend fun getData(): Result<Data> = 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

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)
}
}
32 changes: 1 addition & 31 deletions app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -109,10 +108,6 @@ class LightningRepo @Inject constructor(
private val _nodeEvents = MutableSharedFlow<Event>(extraBufferCapacity = 64)
val nodeEvents = _nodeEvents.asSharedFlow()

private val pendingPayments = Collections.synchronizedSet(mutableSetOf<String>())
private val _pendingPaymentResolution = MutableSharedFlow<PendingPaymentResolution>(extraBufferCapacity = 1)
val pendingPaymentResolution = _pendingPaymentResolution.asSharedFlow()

private val scope = CoroutineScope(bgDispatcher + SupervisorJob())

private val _eventHandlers = ConcurrentHashMap.newKeySet<NodeEventHandler>()
Expand All @@ -126,7 +121,6 @@ class LightningRepo @Inject constructor(
private val syncRetryJob = AtomicReference<Job?>(null)
private val lifecycleMutex = Mutex()
private val isChangingAddressType = AtomicBoolean(false)
private val _activePendingPaymentHash = AtomicReference<String?>(null)

init {
observeConnectivityForSyncRetry()
Expand Down Expand Up @@ -1383,41 +1377,17 @@ 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")
class NodeRunTimeoutError(opName: String) : AppError("Timeout waiting for node to run and execute: '$opName'")
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,
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)

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<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),
)
}
2 changes: 1 addition & 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,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
Expand Down
Loading
Loading