Skip to content
Open
21 changes: 16 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ 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 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
Expand All @@ -179,6 +179,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 +196,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/`
- ALWAYS write the documentation for new features as Markdown files in `docs/`
- 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 @@ -204,18 +206,27 @@ suspend fun getData(): Result<Data> = withContext(Dispatchers.IO) {
- 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 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 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 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 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 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 `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
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
33 changes: 32 additions & 1 deletion app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ 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 @@ -108,6 +109,10 @@ 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 @@ -121,6 +126,7 @@ 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 @@ -448,7 +454,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,16 +1383,41 @@ 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
}
}

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
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package to.bitkit.ui.screens.wallets.send

import androidx.compose.animation.core.EaseInOut
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import to.bitkit.R
import to.bitkit.models.NewTransactionSheetDetails
import to.bitkit.ui.components.BalanceHeaderView
import to.bitkit.ui.components.BodyM
import to.bitkit.ui.components.BottomSheetPreview
import to.bitkit.ui.components.FillHeight
import to.bitkit.ui.components.PrimaryButton
import to.bitkit.ui.components.SecondaryButton
import to.bitkit.ui.components.VerticalSpacer
import to.bitkit.ui.scaffold.SheetTopBar
import to.bitkit.ui.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,
onClose: () -> Unit,
onViewDetails: (String) -> Unit,
viewModel: SendPendingViewModel,
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()

LaunchedEffect(Unit) { viewModel.init(paymentHash, amount) }

uiState.resolution?.let { resolution ->
LaunchedEffect(resolution) {
runCatching {
when (resolution) {
is Resolution.Success -> onPaymentSuccess(resolution.details)
is Resolution.Error -> onPaymentError(resolution.message)
}
}.onFailure { Logger.error("Failed handling payment resolution", it) }
viewModel.onResolutionHandled()
}
}

Content(
amount = uiState.amount,
activityId = uiState.activityId,
onClose = onClose,
onViewDetails = onViewDetails,
)
}

@Composable
private fun Content(
amount: Long,
activityId: String?,
onClose: () -> Unit,
onViewDetails: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.gradientBackground()
.navigationBarsPadding()
) {
SheetTopBar(stringResource(R.string.wallet__send_pending__nav_title))

Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
VerticalSpacer(16.dp)
BalanceHeaderView(sats = amount, modifier = Modifier.fillMaxWidth())

VerticalSpacer(32.dp)
BodyM(stringResource(R.string.wallet__send_pending__description), color = Colors.White64)

FillHeight()
HourglassAnimation(modifier = Modifier.align(Alignment.CenterHorizontally))
FillHeight()

Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
SecondaryButton(
text = stringResource(R.string.wallet__send_details),
enabled = activityId != null,
onClick = { activityId?.let(onViewDetails) },
modifier = Modifier.weight(1f),
)
PrimaryButton(
text = stringResource(R.string.common__close),
onClick = onClose,
modifier = Modifier.weight(1f),
)
}
VerticalSpacer(16.dp)
}
}
}

@Composable
private fun HourglassAnimation(modifier: Modifier = Modifier) {
val infiniteTransition = rememberInfiniteTransition(label = "hourglass")
val rotation by infiniteTransition.animateFloat(
initialValue = -16f,
targetValue = 16f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 3000, easing = EaseInOut),
repeatMode = RepeatMode.Reverse,
),
label = "hourglassRotation",
)
Image(
painter = painterResource(R.drawable.hourglass),
contentDescription = null,
modifier = modifier
.size(256.dp)
.rotate(rotation),
)
}

@Preview(showSystemUi = true)
@Composable
private fun Preview() {
AppThemeSurface {
BottomSheetPreview {
Content(
amount = 50_000L,
activityId = null,
onClose = {},
onViewDetails = {},
modifier = Modifier.sheetHeight(),
)
}
}
}
Loading
Loading