Skip to content

Commit e75b22f

Browse files
authored
Merge branch 'feat/system-widgets-foundation' into feat/price-widget-v61
2 parents 8f3f5a8 + 47f09f0 commit e75b22f

23 files changed

Lines changed: 947 additions & 130 deletions

File tree

.claude/commands/release.md

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -53,18 +53,9 @@ If "Previous tag": ask `"Which tag?"` with a text input (default: `v{oldVersionN
5353

5454
If "master" or if the release is minor/major: `{baseRef} = master`.
5555

56-
### 2c. Finalize Changelog
57-
58-
Read `CHANGELOG.md` and check whether `## [Unreleased]` has any entries beneath it.
59-
60-
**If entries exist:**
61-
1. Replace `## [Unreleased]` with `## [{newVersionName}] - {YYYY-MM-DD}` (today's date)
62-
2. Insert a fresh empty `## [Unreleased]` section above the new version heading
63-
3. Update the compare link references at the bottom of the file:
64-
- Change `[Unreleased]` link to compare from `v{newVersionName}...HEAD`
65-
- Add a new `[{newVersionName}]` link comparing `v{oldVersionName}...v{newVersionName}`
66-
67-
**If no entries:** Print `⚠ CHANGELOG.md has no unreleased entries — continuing without changelog update.` and proceed.
56+
Set `{changelogTarget}`:
57+
- If `{baseRef}` is `master`: `next`
58+
- Otherwise: `hotfix`
6859

6960
### 3. Create Release Branch & Bump Version
7061

@@ -86,18 +77,34 @@ Cherry-pick the commits you need onto this branch now, then continue.
8677
```
8778
Wait for the user to confirm they are done cherry-picking before proceeding.
8879

80+
Finalize changelog after the release branch contains all release commits:
81+
82+
```bash
83+
scripts/collect-changelog.sh --target {changelogTarget}
84+
```
85+
86+
Read `CHANGELOG.md` and check whether `## [Unreleased]` has any entries beneath it after collecting fragments.
87+
88+
**If entries exist:**
89+
1. Replace `## [Unreleased]` with `## [{newVersionName}] - {YYYY-MM-DD}` (today's date)
90+
2. Insert a fresh empty `## [Unreleased]` section above the new version heading
91+
3. Update the compare link references at the bottom of the file:
92+
- Change `[Unreleased]` link to compare from `v{newVersionName}...HEAD`
93+
- Add a new `[{newVersionName}]` link comparing `v{oldVersionName}...v{newVersionName}`
94+
95+
**If no entries:** Print `⚠ CHANGELOG.md has no unreleased entries — continuing without changelog update.` and proceed.
96+
8997
Edit `app/build.gradle.kts`:
9098
- Change `versionCode = {old}` to `versionCode = {newVersionCode}`
9199
- Change `versionName = "{old}"` to `versionName = "{newVersionName}"`
92100

93101
```bash
94102
git add app/build.gradle.kts
95-
# Only stage CHANGELOG.md if step 2c modified it (i.e. unreleased entries were found)
96103
git commit -m "chore: version {newVersionName}"
97104
git push -u origin release-{newVersionName}
98105
```
99106

100-
If step 2c updated `CHANGELOG.md`, also `git add CHANGELOG.md` before the commit.
107+
If changelog collection updated `CHANGELOG.md` or deleted consumed fragments, run `git add CHANGELOG.md changelog.d` before the commit.
101108

102109
### 4. Create Version Bump PR
103110

.cursor/rules/rules.main.mdc

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,14 @@ alwaysApply: true
6363
---
6464

6565
## Changelog rules:
66-
- add an entry under `## [Unreleased]` in `CHANGELOG.md` for `feat:` and `fix:` PRs; skip for `chore:`, `ci:`, `refactor:`, `test:`, `docs:` unless the change is user-facing
67-
- use standard Keep a Changelog categories: `### Added`, `### Changed`, `### Deprecated`, `### Removed`, `### Fixed`, `### Security`
68-
- append `#PR_NUMBER` at the end of each changelog entry when the PR number is known
69-
- place new entries at the top of their category section (newest first)
70-
- never modify released version sections — only edit `## [Unreleased]`
71-
- create category headings on demand (don't add empty stubs)
66+
- never edit `CHANGELOG.md` in normal feature/fix PRs; release automation collects changelog fragments into it
67+
- add exactly one changelog fragment for user-facing `feat:` and `fix:` PRs; skip for `chore:`, `ci:`, `refactor:`, `test:`, `docs:` unless the change is user-facing
68+
- put normal release fragments in `changelog.d/next/` and hotfix fragments in `changelog.d/hotfix/`
69+
- name fragments `<issue-or-pr>.<category>.md`, where category is one of `added`, `changed`, `deprecated`, `removed`, `fixed`, or `security`
70+
- write the fragment as one polished user-facing sentence without a leading bullet and without a PR number
71+
- never add multiple changelog fragments for the same PR — summarize all changes in one concise fragment
72+
- release commits consume fragments with `scripts/collect-changelog.sh --target next|hotfix`, update `CHANGELOG.md`, and delete consumed fragment files
73+
- never modify released version sections manually
7274

7375
---
7476

.github/pull_request_template.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<!-- Closes | Fixes | Resolves #ISSUE_ID -->
2-
<!-- Changelog: Add an entry under ## [Unreleased] in CHANGELOG.md for user-facing changes (skip for chores/CI/refactors). -->
2+
<!-- Changelog: For user-facing changes, add one fragment in changelog.d/next/ or changelog.d/hotfix/. Do not edit CHANGELOG.md in normal PRs. -->
33
<!-- Brief summary of the PR changes, linking to the related resources (issue/design/bug/etc) if applicable. -->
44

55
### Description

AGENTS.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -240,13 +240,14 @@ suspend fun getData(): Result<Data> = withContext(Dispatchers.IO) {
240240

241241
### Changelog
242242

243-
- ALWAYS add exactly ONE entry per PR under `## [Unreleased]` in `CHANGELOG.md` for `feat:` and `fix:` PRs; skip for `chore:`, `ci:`, `refactor:`, `test:`, `docs:` unless the change is user-facing
244-
- NEVER add multiple changelog lines for the same PR — summarize all changes in a single concise entry
245-
- USE standard Keep a Changelog categories: `### Added`, `### Changed`, `### Deprecated`, `### Removed`, `### Fixed`, `### Security`
246-
- ALWAYS append `#PR_NUMBER` at the end of each changelog entry when the PR number is known
247-
- ALWAYS place new entries at the top of their category section (newest first)
248-
- NEVER modify released version sections — only edit `## [Unreleased]`
249-
- ALWAYS create category headings on demand (don't add empty stubs)
243+
- NEVER edit `CHANGELOG.md` in normal feature/fix PRs; release automation collects changelog fragments into it
244+
- ALWAYS add exactly ONE changelog fragment for user-facing `feat:` and `fix:` PRs; skip for `chore:`, `ci:`, `refactor:`, `test:`, `docs:` unless the change is user-facing
245+
- PUT normal release fragments in `changelog.d/next/` and hotfix fragments in `changelog.d/hotfix/`
246+
- NAME fragments `<issue-or-pr>.<category>.md`, where category is one of `added`, `changed`, `deprecated`, `removed`, `fixed`, or `security`
247+
- WRITE the fragment as one polished user-facing sentence without a leading bullet and without a PR number
248+
- NEVER add multiple changelog fragments for the same PR — summarize all changes in one concise fragment
249+
- Release commits consume fragments with `scripts/collect-changelog.sh --target next|hotfix`, update `CHANGELOG.md`, and delete consumed fragment files
250+
- NEVER modify released version sections manually
250251

251252
### Device Debugging (adb)
252253

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111
- Improve Pubky profile restore, contact editing, and contact routing flows #905
1212

1313
### Fixed
14+
- Fix probe results and add keysend probes #920
15+
- Align top bar back arrow and passphrase input cursor/placeholder with iOS #906
1416
- Polish Terms of Use screen padding to match iOS #903
1517

1618
### Added

app/src/main/java/to/bitkit/models/USat.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ value class USat(val value: ULong) : Comparable<USat> {
1717
/** Saturating addition: caps at ULong.MAX_VALUE if result would overflow. */
1818
operator fun plus(other: USat): ULong =
1919
if (value <= ULong.MAX_VALUE - other.value) value + other.value else ULong.MAX_VALUE
20+
21+
/** Saturating multiplication: caps at ULong.MAX_VALUE if result would overflow. */
22+
operator fun times(other: USat): ULong =
23+
if (other.value == 0uL || value <= ULong.MAX_VALUE / other.value) value * other.value else ULong.MAX_VALUE
2024
}
2125

2226
/**

app/src/main/java/to/bitkit/repositories/LightningRepo.kt

Lines changed: 114 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
2424
import kotlinx.coroutines.flow.asSharedFlow
2525
import kotlinx.coroutines.flow.asStateFlow
2626
import kotlinx.coroutines.flow.distinctUntilChanged
27+
import kotlinx.coroutines.flow.filter
2728
import kotlinx.coroutines.flow.first
2829
import kotlinx.coroutines.flow.map
30+
import kotlinx.coroutines.flow.mapNotNull
31+
import kotlinx.coroutines.flow.onSubscription
2932
import kotlinx.coroutines.flow.update
3033
import kotlinx.coroutines.isActive
3134
import kotlinx.coroutines.launch
@@ -44,6 +47,7 @@ import org.lightningdevkit.ldknode.ClosureReason
4447
import org.lightningdevkit.ldknode.Event
4548
import org.lightningdevkit.ldknode.NodeStatus
4649
import org.lightningdevkit.ldknode.PaymentDetails
50+
import org.lightningdevkit.ldknode.PaymentHash
4751
import org.lightningdevkit.ldknode.PaymentId
4852
import org.lightningdevkit.ldknode.PeerDetails
4953
import org.lightningdevkit.ldknode.SpendableUtxo
@@ -60,6 +64,7 @@ import to.bitkit.ext.nowTimestamp
6064
import to.bitkit.ext.toPeerDetailsList
6165
import to.bitkit.models.ALL_ADDRESS_TYPE_STRINGS
6266
import to.bitkit.models.CoinSelectionPreference
67+
import to.bitkit.models.MSat
6368
import to.bitkit.models.NATIVE_WITNESS_TYPES
6469
import to.bitkit.models.NodeLifecycleState
6570
import to.bitkit.models.OpenChannelResult
@@ -121,6 +126,8 @@ class LightningRepo @Inject constructor(
121126
val isRecoveryMode = _isRecoveryMode.asStateFlow()
122127

123128
private val channelCache = ConcurrentHashMap<String, ChannelDetails>()
129+
private val probeOutcomeCache = ConcurrentHashMap<PaymentId, ProbeOutcome>()
130+
private val probeOutcomeSignal = MutableSharedFlow<ProbeOutcome>(extraBufferCapacity = 64)
124131

125132
private val syncMutex = Mutex()
126133
private val syncPending = AtomicBoolean(false)
@@ -420,6 +427,7 @@ class LightningRepo @Inject constructor(
420427

421428
private suspend fun onEvent(event: Event) {
422429
handleLdkEvent(event)
430+
recordProbeOutcome(event)
423431
_eventHandlers.toList().forEach {
424432
runCatching { it.invoke(event) }
425433
}
@@ -441,12 +449,14 @@ class LightningRepo @Inject constructor(
441449
suspend fun stop(): Result<Unit> = withContext(bgDispatcher) {
442450
lifecycleMutex.withLock {
443451
if (_lightningState.value.nodeLifecycleState.isStoppedOrStopping()) {
452+
clearProbeOutcomes()
444453
return@withLock Result.success(Unit)
445454
}
446455

447456
runCatching {
448457
_lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Stopping) }
449458
lightningService.stop()
459+
clearProbeOutcomes()
450460
_lightningState.update { LightningState(nodeLifecycleState = NodeLifecycleState.Stopped) }
451461
}.onFailure {
452462
Logger.error("Node stop error", it, context = TAG)
@@ -529,6 +539,21 @@ class LightningRepo @Inject constructor(
529539
}
530540
}
531541

542+
private suspend fun recordProbeOutcome(event: Event) {
543+
val outcome = when (event) {
544+
is Event.ProbeSuccessful -> ProbeOutcome.Success(event.paymentId, event.paymentHash)
545+
is Event.ProbeFailed -> ProbeOutcome.Failure(event.paymentId, event.paymentHash, event.shortChannelId)
546+
else -> return
547+
}
548+
549+
probeOutcomeCache[outcome.paymentId] = outcome
550+
probeOutcomeSignal.emit(outcome)
551+
}
552+
553+
private fun clearProbeOutcomes() {
554+
probeOutcomeCache.clear()
555+
}
556+
532557
private suspend fun registerClosedChannel(channelId: String, reason: ClosureReason?) = withContext(bgDispatcher) {
533558
runCatching {
534559
val channel = channelCache[channelId] ?: run {
@@ -582,6 +607,7 @@ class LightningRepo @Inject constructor(
582607
stop().mapCatching {
583608
Logger.debug("node stopped, calling wipeStorage", context = TAG)
584609
lightningService.wipeStorage(walletIndex)
610+
clearProbeOutcomes()
585611
_lightningState.update {
586612
LightningState(
587613
nodeStatus = it.nodeStatus,
@@ -1363,23 +1389,74 @@ class LightningRepo @Inject constructor(
13631389
// endregion
13641390

13651391
// region probing
1366-
suspend fun sendProbeForInvoice(bolt11: String, amountSats: ULong? = null): Result<Unit> =
1392+
suspend fun sendProbeForInvoice(bolt11: String, amountSats: ULong? = null): Result<ProbeDispatch> =
13671393
executeWhenNodeRunning("sendProbeForInvoice") {
13681394
Logger.debug(
1369-
"sendProbeForInvoice: amountSats=${amountSats ?: "null (using invoice amount)"}",
1370-
context = TAG
1395+
"sendProbeForInvoice: amountSats='${amountSats ?: "null (using invoice amount)"}'",
1396+
context = TAG,
13711397
)
1372-
runCatching {
1373-
if (amountSats != null) {
1374-
val amountMsat = amountSats * 1000u
1375-
lightningService.sendProbesUsingAmount(bolt11, amountMsat)
1376-
} else {
1377-
lightningService.sendProbes(bolt11)
1378-
}
1379-
}.getOrElse {
1380-
Result.failure(it)
1398+
val result = if (amountSats != null) {
1399+
val amountMsat = amountSats.safe() * MSat.PER_SAT.safe()
1400+
lightningService.sendProbesUsingAmount(bolt11, amountMsat)
1401+
} else {
1402+
lightningService.sendProbes(bolt11)
13811403
}
1404+
1405+
result.map { ProbeDispatch(paymentIds = it) }
13821406
}
1407+
1408+
suspend fun sendProbeForNode(nodeId: String, amountSats: ULong): Result<ProbeDispatch> =
1409+
executeWhenNodeRunning("sendProbeForNode") {
1410+
Logger.debug(
1411+
"Sending keysend probe to nodeId='$nodeId' amountSats='$amountSats'",
1412+
context = TAG,
1413+
)
1414+
val amountMsat = amountSats.safe() * MSat.PER_SAT.safe()
1415+
lightningService.sendKeysendProbe(nodeId, amountMsat).map {
1416+
ProbeDispatch(paymentIds = it)
1417+
}
1418+
}
1419+
1420+
suspend fun waitForProbeOutcome(
1421+
paymentIds: Set<PaymentId>,
1422+
timeout: Duration = PROBE_TIMEOUT,
1423+
): Result<ProbeOutcome> = withContext(bgDispatcher) {
1424+
if (paymentIds.isEmpty()) {
1425+
return@withContext Result.failure(ProbeError.NoProbeHandles())
1426+
}
1427+
1428+
val trackedIds = paymentIds.toSet()
1429+
val outcome = withTimeoutOrNull(timeout) {
1430+
val pending = trackedIds.toMutableSet()
1431+
var lastFailure: ProbeOutcome.Failure? = null
1432+
1433+
probeOutcomeSignal
1434+
.onSubscription {
1435+
trackedIds.forEach { id ->
1436+
probeOutcomeCache[id]?.let { emit(it) }
1437+
}
1438+
}
1439+
.filter { it.paymentId in trackedIds }
1440+
.mapNotNull { probeOutcome ->
1441+
if (!pending.remove(probeOutcome.paymentId)) return@mapNotNull null
1442+
1443+
probeOutcomeCache.remove(probeOutcome.paymentId)
1444+
when (probeOutcome) {
1445+
is ProbeOutcome.Success -> probeOutcome
1446+
is ProbeOutcome.Failure -> {
1447+
lastFailure = probeOutcome
1448+
if (pending.isEmpty()) lastFailure else null
1449+
}
1450+
}
1451+
}
1452+
.first()
1453+
}
1454+
1455+
trackedIds.forEach { probeOutcomeCache.remove(it) }
1456+
1457+
outcome?.let { Result.success(it) }
1458+
?: Result.failure(ProbeError.TimedOut())
1459+
}
13831460
// endregion
13841461

13851462
suspend fun restartNode(): Result<Unit> = withContext(bgDispatcher) {
@@ -1404,6 +1481,7 @@ class LightningRepo @Inject constructor(
14041481
private const val CHANNELS_READY_TIMEOUT_MS = 15_000L
14051482
private const val CHANNELS_USABLE_TIMEOUT_MS = 15_000L
14061483
val SEND_LN_TIMEOUT = 10.seconds
1484+
private val PROBE_TIMEOUT = 60.seconds
14071485
}
14081486
}
14091487

@@ -1413,6 +1491,10 @@ class NodeStopTimeoutError : AppError("Timeout waiting for node to stop")
14131491
class NodeRunTimeoutError(opName: String) : AppError("Timeout waiting for node to run and execute: '$opName'")
14141492
class GetPaymentsError : AppError("It wasn't possible get the payments")
14151493
class SyncUnhealthyError : AppError("Wallet sync failed before send")
1494+
sealed class ProbeError(message: String) : AppError(message) {
1495+
class NoProbeHandles : ProbeError("No probe handles returned")
1496+
class TimedOut : ProbeError("Probe timed out")
1497+
}
14161498

14171499
@Stable
14181500
data class LightningState(
@@ -1436,3 +1518,23 @@ data class LightningState(
14361518
val isSyncHealthy: Boolean
14371519
get() = lastSyncError == null && lastSuccessfulSyncAt != null
14381520
}
1521+
1522+
data class ProbeDispatch(
1523+
val paymentIds: Set<PaymentId>,
1524+
)
1525+
1526+
sealed interface ProbeOutcome {
1527+
val paymentId: PaymentId
1528+
val paymentHash: PaymentHash
1529+
1530+
data class Success(
1531+
override val paymentId: PaymentId,
1532+
override val paymentHash: PaymentHash,
1533+
) : ProbeOutcome
1534+
1535+
data class Failure(
1536+
override val paymentId: PaymentId,
1537+
override val paymentHash: PaymentHash,
1538+
val shortChannelId: ULong?,
1539+
) : ProbeOutcome
1540+
}

0 commit comments

Comments
 (0)