Skip to content

Commit 49600f9

Browse files
authored
Merge branch 'master' into fix/migration-with-bad-connection
2 parents c55b5de + 05f5487 commit 49600f9

32 files changed

Lines changed: 457 additions & 465 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ suspend fun getData(): Result<Data> = withContext(Dispatchers.IO) {
178178
- NEVER use `runBlocking` in suspend functions
179179
- ALWAYS pass the TAG as context to `Logger` calls, e.g. `Logger.debug("message", context = TAG)`
180180
- NEVER add `e = ` named parameter to Logger calls
181+
- 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
181182
- ALWAYS log errors at the final handling layer where the error is acted upon, not in intermediate layers that just propagate it
182183
- ALWAYS use the Result API instead of try-catch
183184
- NEVER wrap methods returning `Result<T>` in try-catch
@@ -221,4 +222,4 @@ suspend fun getData(): Result<Data> = withContext(Dispatchers.IO) {
221222
- Use `LightningRepo` to defining the business logic for the node operations, usually delegating to `LightningService`
222223
- Use `WakeNodeWorker` to manage the handling of remote notifications received via cloud messages
223224
- Use `*Services` to wrap rust library code exposed via bindings
224-
- Use CQRS pattern of Command + Handler like it's done in the `NotifyPaymentReceived` + `NotifyPaymentReceivedHandler` setup
225+
- Use CQRS pattern of Command + Handler like it's done in the `NotifyPaymentReceived` + `NotifyPaymentReceivedHandler` setup

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,16 @@ To build the mainnet flavor for release run:
153153
./gradlew assembleMainnetRelease
154154
```
155155

156+
#### Android App Bundle (AAB)
157+
158+
For Play Store submission, build an AAB instead of APK:
159+
160+
```sh
161+
./gradlew bundleMainnetRelease
162+
```
163+
164+
AAB is generated in `app/build/outputs/bundle/mainnetRelease/`.
165+
156166
### Build for E2E Testing
157167

158168
Pass `E2E=true` and build any flavor. By default, E2E uses a local Electrum override.

app/build.gradle.kts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,14 @@ val keystoreProperties by lazy {
3737
keystoreProperties
3838
}
3939

40-
val locales = listOf("en", "ar", "ca", "cs", "de", "el", "es", "fr", "it", "nl", "pl", "pt", "ru")
40+
// Android resource qualifier format for androidResources.localeFilters
41+
val androidLocales = listOf(
42+
"en", "ar", "b+es+419", "ca", "cs", "de", "el", "es", "es-rES", "fr", "it", "nl", "pl", "pt", "pt-rBR", "ru"
43+
)
44+
// BCP 47 format for BuildConfig.LOCALES (used with Locale.forLanguageTag())
45+
val bcp47Locales = listOf(
46+
"en", "ar", "es-419", "ca", "cs", "de", "el", "es", "es-ES", "fr", "it", "nl", "pl", "pt", "pt-BR", "ru"
47+
)
4148
val e2eBackendEnv = System.getenv("E2E_BACKEND") ?: "local"
4249

4350
android {
@@ -47,16 +54,16 @@ android {
4754
applicationId = "to.bitkit"
4855
minSdk = 28
4956
targetSdk = 36
50-
versionCode = 170
51-
versionName = "2.0.0-rc.4"
57+
versionCode = 172
58+
versionName = "2.0.0-rc.6"
5259
testInstrumentationRunner = "to.bitkit.test.HiltTestRunner"
5360
vectorDrawables {
5461
useSupportLibrary = true
5562
}
5663
buildConfigField("boolean", "E2E", System.getenv("E2E")?.toBoolean()?.toString() ?: "false")
5764
buildConfigField("String", "E2E_BACKEND", "\"$e2eBackendEnv\"")
5865
buildConfigField("boolean", "GEO", System.getenv("GEO")?.toBoolean()?.toString() ?: "true")
59-
buildConfigField("String", "LOCALES", "\"${locales.joinToString(",")}\"")
66+
buildConfigField("String", "LOCALES", "\"${bcp47Locales.joinToString(",")}\"")
6067
}
6168

6269
flavorDimensions += "network"
@@ -146,7 +153,7 @@ android {
146153
}
147154
androidResources {
148155
@Suppress("UnstableApiUsage")
149-
localeFilters.addAll(locales)
156+
localeFilters.addAll(androidLocales)
150157
@Suppress("UnstableApiUsage")
151158
generateLocaleConfig = true
152159
}

app/src/main/AndroidManifest.xml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
<uses-permission android:name="android.permission.INTERNET" />
1818
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
1919
<uses-permission android:name="android.permission.CAMERA" />
20-
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
2120
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
2221
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
2322
<!-- Required for E2E tests connecting to local Electrum server (Android 16+) -->
@@ -146,13 +145,13 @@
146145
See https://goo.gl/l4GJaQ -->
147146
<meta-data
148147
android:name="com.google.firebase.messaging.default_notification_icon"
149-
android:resource="@drawable/ic_notification" />
148+
android:resource="@drawable/ic_bitkit_outlined" />
150149
<!-- Set color used with incoming notification messages.
151150
This is used when no color is set for the incoming notification message.
152151
See https://goo.gl/6BKBk7 -->
153152
<meta-data
154153
android:name="com.google.firebase.messaging.default_notification_color"
155-
android:resource="@color/teal_200" />
154+
android:resource="@color/brand" />
156155
<!-- Set default notifications channel for Firebase. -->
157156
<meta-data
158157
android:name="com.google.firebase.messaging.default_notification_channel_id"

app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,8 @@ class LightningNodeService : Service() {
146146
override fun onDestroy() {
147147
Logger.debug("onDestroy", context = TAG)
148148
serviceScope.launch {
149-
lightningRepo.stop().onSuccess {
150-
serviceScope.cancel()
151-
}
149+
lightningRepo.stop()
150+
serviceScope.cancel()
152151
}
153152
super.onDestroy()
154153
}

app/src/main/java/to/bitkit/env/Env.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ internal object Env {
4343

4444
const val FILE_PROVIDER_AUTHORITY = "${BuildConfig.APPLICATION_ID}.fileprovider"
4545
const val SUPPORT_EMAIL = "support@synonym.to"
46-
const val DEFAULT_INVOICE_MESSAGE = "Bitkit"
4746
const val PIN_LENGTH = 4
4847
const val PIN_ATTEMPTS = 8
4948

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

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ class BlocktankRepo @Inject constructor(
195195

196196
suspend fun createCjit(
197197
amountSats: ULong,
198-
description: String = Env.DEFAULT_INVOICE_MESSAGE,
198+
description: String = "",
199199
): Result<IcJitEntry> = withContext(bgDispatcher) {
200200
runCatching {
201201
if (coreService.isGeoBlocked()) throw ServiceError.GeoBlocked()
@@ -427,6 +427,8 @@ class BlocktankRepo @Inject constructor(
427427
require(code.isNotBlank()) { "Gift code cannot be blank" }
428428
require(amount > 0u) { "Gift amount must be positive" }
429429

430+
Logger.debug("Starting gift code claim: amount=$amount, timeout=$waitTimeout", context = TAG)
431+
430432
lightningRepo.executeWhenNodeRunning(
431433
operationName = "claimGiftCode",
432434
waitTimeout = waitTimeout,
@@ -436,9 +438,16 @@ class BlocktankRepo @Inject constructor(
436438
val channels = lightningRepo.getChannelsAsync().getOrThrow()
437439
val maxInboundCapacity = channels.calculateRemoteBalance()
438440

441+
Logger.debug(
442+
"Liquidity check: maxInbound=$maxInboundCapacity, required=$amount",
443+
context = TAG
444+
)
445+
439446
if (maxInboundCapacity >= amount) {
440-
Result.success(claimGiftCodeWithLiquidity(code))
447+
Logger.debug("Sufficient liquidity available, claiming with existing channel", context = TAG)
448+
Result.success(claimGiftCodeWithLiquidity(code, amount))
441449
} else {
450+
Logger.debug("Insufficient liquidity, opening new channel", context = TAG)
442451
Result.success(claimGiftCodeWithoutLiquidity(code, amount))
443452
}
444453
}.getOrThrow()
@@ -447,33 +456,53 @@ class BlocktankRepo @Inject constructor(
447456
}
448457
}
449458

450-
private suspend fun claimGiftCodeWithLiquidity(code: String): GiftClaimResult {
459+
private suspend fun claimGiftCodeWithLiquidity(code: String, amount: ULong): GiftClaimResult {
451460
val invoice = lightningRepo.createInvoice(
452461
amountSats = null,
453462
description = "blocktank-gift-code:$code",
454463
expirySeconds = 3600u,
455464
).getOrThrow()
456465

457-
ServiceQueue.CORE.background {
466+
Logger.debug("Created invoice for gift code, requesting payment from LSP", context = TAG)
467+
468+
val giftResponse = ServiceQueue.CORE.background {
458469
giftPay(invoice = invoice)
459470
}
460471

461-
return GiftClaimResult.SuccessWithLiquidity
472+
Logger.debug("Gift payment request completed: id=${giftResponse.id}", context = TAG)
473+
474+
return GiftClaimResult.SuccessWithLiquidity(
475+
paymentHashOrTxId = giftResponse.bolt11PaymentId ?: giftResponse.id,
476+
sats = giftResponse.bolt11Payment?.paidSat?.toLong()
477+
?: giftResponse.appliedGiftCode?.giftSat?.toLong()
478+
?: amount.toLong(),
479+
invoice = invoice,
480+
code = code,
481+
)
462482
}
463483

464484
private suspend fun claimGiftCodeWithoutLiquidity(code: String, amount: ULong): GiftClaimResult {
465485
val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted()
466486

487+
Logger.debug("Creating gift order for code (insufficient liquidity)", context = TAG)
488+
467489
val order = ServiceQueue.CORE.background {
468490
giftOrder(clientNodeId = nodeId, code = "blocktank-gift-code:$code")
469491
}
470492

471-
val orderId = checkNotNull(order.orderId) { "Order ID is null" }
493+
val orderId = checkNotNull(order.orderId) { "Order ID is null after gift order creation" }
494+
Logger.debug("Gift order created: $orderId", context = TAG)
472495

473496
val openedOrder = openChannel(orderId).getOrThrow()
497+
Logger.debug("Channel opened for gift order: ${openedOrder.id}", context = TAG)
498+
499+
val fundingTxId = openedOrder.channel?.fundingTx?.id
500+
if (fundingTxId == null) {
501+
Logger.warn("Channel opened but funding transaction ID is null", context = TAG)
502+
}
474503

475504
return GiftClaimResult.SuccessWithoutLiquidity(
476-
paymentHashOrTxId = openedOrder.channel?.fundingTx?.id ?: orderId,
505+
paymentHashOrTxId = fundingTxId ?: orderId,
477506
sats = amount.toLong(),
478507
invoice = openedOrder.payment?.bolt11Invoice?.request ?: "",
479508
code = code,
@@ -498,11 +527,22 @@ data class BlocktankState(
498527
)
499528

500529
sealed class GiftClaimResult {
501-
object SuccessWithLiquidity : GiftClaimResult()
530+
abstract val paymentHashOrTxId: String
531+
abstract val sats: Long
532+
abstract val invoice: String
533+
abstract val code: String
534+
535+
data class SuccessWithLiquidity(
536+
override val paymentHashOrTxId: String,
537+
override val sats: Long,
538+
override val invoice: String,
539+
override val code: String,
540+
) : GiftClaimResult()
541+
502542
data class SuccessWithoutLiquidity(
503-
val paymentHashOrTxId: String,
504-
val sats: Long,
505-
val invoice: String,
506-
val code: String,
543+
override val paymentHashOrTxId: String,
544+
override val sats: Long,
545+
override val invoice: String,
546+
override val code: String,
507547
) : GiftClaimResult()
508548
}

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

Lines changed: 61 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
1111
import kotlinx.coroutines.flow.map
1212
import kotlinx.coroutines.flow.update
1313
import kotlinx.coroutines.launch
14+
import org.lightningdevkit.ldknode.ChannelDetails
1415
import to.bitkit.data.CacheStore
1516
import to.bitkit.di.BgDispatcher
1617
import to.bitkit.models.BackupCategory
@@ -43,68 +44,80 @@ class HealthRepo @Inject constructor(
4344
observeBackupStatus()
4445
}
4546

46-
@Suppress("CyclomaticComplexMethod")
4747
private fun collectState() {
48-
val internetHealthState = connectivityRepo.isOnline.map { connectivityState ->
49-
when (connectivityState) {
50-
ConnectivityState.CONNECTED -> HealthState.READY
51-
ConnectivityState.CONNECTING -> HealthState.PENDING
52-
ConnectivityState.DISCONNECTED -> HealthState.ERROR
53-
}
54-
}
48+
val internetHealthState = connectivityRepo.isOnline.map { it.asHealth() }
5549

5650
repoScope.launch {
5751
combine(
5852
internetHealthState,
5953
lightningRepo.lightningState,
6054
) { internetHealth, lightningState ->
61-
val isOnline = internetHealth == HealthState.READY
62-
val nodeLifecycleState = lightningState.nodeLifecycleState
63-
64-
val nodeHealth = when {
65-
!isOnline -> HealthState.ERROR
66-
else -> nodeLifecycleState.asHealth()
67-
}
68-
69-
val electrumHealth = when {
70-
!isOnline -> HealthState.ERROR
71-
nodeLifecycleState.isRunning() -> HealthState.READY
72-
nodeLifecycleState.canRun() -> HealthState.PENDING
73-
else -> HealthState.ERROR
74-
}
75-
76-
val channelsHealth = when {
77-
!isOnline -> HealthState.ERROR
78-
else -> {
79-
val channels = lightningState.channels
80-
val hasOpenChannels = channels.any { it.isChannelReady }
81-
val hasPendingChannels = channels.any { !it.isChannelReady }
82-
83-
when {
84-
hasOpenChannels -> HealthState.READY
85-
hasPendingChannels -> HealthState.PENDING
86-
else -> HealthState.ERROR
87-
}
88-
}
89-
}
90-
91-
AppHealthState(
92-
internet = internetHealth,
93-
electrum = electrumHealth,
94-
node = nodeHealth,
95-
channels = channelsHealth,
96-
)
55+
computeHealthState(internetHealth, lightningState)
9756
}.collect { newHealthState ->
98-
updateState { currentState ->
99-
newHealthState.copy(
100-
backups = currentState.backups,
101-
app = currentState.app,
57+
updateState {
58+
it.copy(
59+
internet = newHealthState.internet,
60+
electrum = newHealthState.electrum,
61+
node = newHealthState.node,
62+
channels = newHealthState.channels,
10263
)
10364
}
10465
}
10566
}
10667
}
10768

69+
@Suppress("CyclomaticComplexMethod")
70+
private fun computeHealthState(internetHealth: HealthState, lightningState: LightningState): AppHealthState {
71+
val isOnline = internetHealth == HealthState.READY
72+
val nodeLifecycleState = lightningState.nodeLifecycleState
73+
val isSyncing = lightningState.isSyncingWallet
74+
val hasSyncError = lightningState.lastSyncError != null
75+
76+
val nodeHealth = when {
77+
!isOnline -> HealthState.ERROR
78+
isSyncing -> HealthState.PENDING
79+
hasSyncError && nodeLifecycleState.isRunning() -> HealthState.ERROR
80+
else -> nodeLifecycleState.asHealth()
81+
}
82+
83+
val electrumHealth = when {
84+
!isOnline -> HealthState.ERROR
85+
isSyncing -> HealthState.PENDING
86+
hasSyncError && nodeLifecycleState.isRunning() -> HealthState.ERROR
87+
nodeLifecycleState.isRunning() -> HealthState.READY
88+
nodeLifecycleState.canRun() -> HealthState.PENDING
89+
else -> HealthState.ERROR
90+
}
91+
92+
val channelsHealth = when {
93+
!isOnline -> HealthState.ERROR
94+
else -> computeChannelsHealth(lightningState.channels)
95+
}
96+
97+
return AppHealthState(
98+
internet = internetHealth,
99+
electrum = electrumHealth,
100+
node = nodeHealth,
101+
channels = channelsHealth,
102+
)
103+
}
104+
105+
private fun computeChannelsHealth(channels: List<ChannelDetails>): HealthState {
106+
val hasOpenChannels = channels.any { it.isChannelReady }
107+
val hasPendingChannels = channels.any { !it.isChannelReady }
108+
return when {
109+
hasOpenChannels -> HealthState.READY
110+
hasPendingChannels -> HealthState.PENDING
111+
else -> HealthState.ERROR
112+
}
113+
}
114+
115+
private fun ConnectivityState.asHealth() = when (this) {
116+
ConnectivityState.CONNECTED -> HealthState.READY
117+
ConnectivityState.CONNECTING -> HealthState.PENDING
118+
ConnectivityState.DISCONNECTED -> HealthState.ERROR
119+
}
120+
108121
private fun observePaidOrdersState() {
109122
repoScope.launch {
110123
blocktankRepo.blocktankState.map { it.paidOrders }.distinctUntilChanged().collect { paidOrders ->

0 commit comments

Comments
 (0)