Skip to content

Commit fa7e0f2

Browse files
authored
Merge branch 'master' into fix/dismissable-foreground-notification
2 parents 1ba3c0e + cdd989c commit fa7e0f2

7 files changed

Lines changed: 177 additions & 42 deletions

File tree

AGENTS.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,9 +147,9 @@ fun updateState(action: Action) {
147147
```kotlin
148148
suspend fun getData(): Result<Data> = withContext(Dispatchers.IO) {
149149
runCatching {
150-
Result.success(apiService.fetchData())
151-
}.onFailure { e ->
152-
Logger.error("Failed", e = e, context = TAG)
150+
apiService.fetchData()
151+
}.onFailure {
152+
Logger.error("Failed", it, context = TAG)
153153
}
154154
}
155155
```
@@ -176,6 +176,7 @@ suspend fun getData(): Result<Data> = withContext(Dispatchers.IO) {
176176
- ALWAYS acknowledge datastore async operations run synchronously in a suspend context
177177
- NEVER use `runBlocking` in suspend functions
178178
- ALWAYS pass the TAG as context to `Logger` calls, e.g. `Logger.debug("message", context = TAG)`
179+
- NEVER add `e = ` named parameter to Logger calls
179180
- ALWAYS log errors at the final handling layer where the error is acted upon, not in intermediate layers that just propagate it
180181
- ALWAYS use the Result API instead of try-catch
181182
- NEVER wrap methods returning `Result<T>` in try-catch

app/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ android {
4747
applicationId = "to.bitkit"
4848
minSdk = 28
4949
targetSdk = 36
50-
versionCode = 169
51-
versionName = "2.0.0-rc.3"
50+
versionCode = 170
51+
versionName = "2.0.0-rc.4"
5252
testInstrumentationRunner = "to.bitkit.test.HiltTestRunner"
5353
vectorDrawables {
5454
useSupportLibrary = true

app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt

Lines changed: 77 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,59 +10,105 @@ import com.synonym.vssclient.vssNewClientWithLnurlAuth
1010
import com.synonym.vssclient.vssStore
1111
import kotlinx.coroutines.CompletableDeferred
1212
import kotlinx.coroutines.CoroutineDispatcher
13+
import kotlinx.coroutines.delay
14+
import kotlinx.coroutines.sync.Mutex
15+
import kotlinx.coroutines.sync.withLock
1316
import kotlinx.coroutines.withContext
1417
import kotlinx.coroutines.withTimeout
1518
import to.bitkit.data.keychain.Keychain
1619
import to.bitkit.di.IoDispatcher
1720
import to.bitkit.env.Env
1821
import to.bitkit.utils.Logger
19-
import to.bitkit.utils.ServiceError
2022
import javax.inject.Inject
2123
import javax.inject.Singleton
2224
import kotlin.time.Duration.Companion.seconds
2325

26+
class MnemonicNotAvailableException : Exception("Mnemonic not available")
27+
2428
@Singleton
2529
class VssBackupClient @Inject constructor(
2630
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
2731
private val vssStoreIdProvider: VssStoreIdProvider,
2832
private val keychain: Keychain,
2933
) {
3034
private var isSetup = CompletableDeferred<Unit>()
35+
private val setupMutex = Mutex()
3136

32-
suspend fun setup(walletIndex: Int = 0) = withContext(ioDispatcher) {
33-
runCatching {
34-
withTimeout(30.seconds) {
35-
Logger.debug("VSS client setting up…", context = TAG)
36-
val vssUrl = Env.vssServerUrl
37-
val lnurlAuthServerUrl = Env.lnurlAuthServerUrl
38-
val vssStoreId = vssStoreIdProvider.getVssStoreId(walletIndex)
39-
Logger.verbose("Building VSS client with vssUrl: '$vssUrl'", context = TAG)
40-
Logger.verbose("Building VSS client with lnurlAuthServerUrl: '$lnurlAuthServerUrl'", context = TAG)
41-
if (lnurlAuthServerUrl.isNotEmpty()) {
42-
val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)
43-
?: throw ServiceError.MnemonicNotFound()
44-
val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name)
37+
suspend fun setup(walletIndex: Int = 0): Result<Unit> = withContext(ioDispatcher) {
38+
setupMutex.withLock {
39+
runCatching {
40+
if (isSetup.isCompleted && !isSetup.isCancelled) {
41+
runCatching { isSetup.await() }.onSuccess { return@runCatching }
42+
}
43+
44+
val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)
45+
?: throw MnemonicNotAvailableException()
4546

46-
vssNewClientWithLnurlAuth(
47-
baseUrl = vssUrl,
48-
storeId = vssStoreId,
49-
mnemonic = mnemonic,
50-
passphrase = passphrase,
51-
lnurlAuthServerUrl = lnurlAuthServerUrl,
52-
)
53-
} else {
54-
vssNewClient(
55-
baseUrl = vssUrl,
56-
storeId = vssStoreId,
57-
)
47+
withTimeout(30.seconds) {
48+
Logger.debug("VSS client setting up…", context = TAG)
49+
val vssUrl = Env.vssServerUrl
50+
val lnurlAuthServerUrl = Env.lnurlAuthServerUrl
51+
val vssStoreId = vssStoreIdProvider.getVssStoreId(walletIndex)
52+
Logger.verbose("Building VSS client with vssUrl: '$vssUrl'", context = TAG)
53+
Logger.verbose("Building VSS client with lnurlAuthServerUrl: '$lnurlAuthServerUrl'", context = TAG)
54+
if (lnurlAuthServerUrl.isNotEmpty()) {
55+
val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name)
56+
57+
vssNewClientWithLnurlAuth(
58+
baseUrl = vssUrl,
59+
storeId = vssStoreId,
60+
mnemonic = mnemonic,
61+
passphrase = passphrase,
62+
lnurlAuthServerUrl = lnurlAuthServerUrl,
63+
)
64+
} else {
65+
vssNewClient(
66+
baseUrl = vssUrl,
67+
storeId = vssStoreId,
68+
)
69+
}
70+
isSetup.complete(Unit)
71+
Logger.info("VSS client setup with server: '$vssUrl'", context = TAG)
5872
}
59-
isSetup.complete(Unit)
60-
Logger.info("VSS client setup with server: '$vssUrl'", context = TAG)
73+
}.onFailure {
74+
isSetup.completeExceptionally(it)
75+
Logger.error("VSS client setup error", it, context = TAG)
76+
}
77+
}
78+
}
79+
80+
class SetupRetryLogger {
81+
var onSuccess: (attempt: Int) -> Unit = {}
82+
var onRetry: (attempt: Int, maxAttempts: Int, delayMs: Long) -> Unit = { _, _, _ -> }
83+
var onExhausted: (maxAttempts: Int) -> Unit = {}
84+
}
85+
86+
suspend fun setupWithRetry(
87+
maxAttempts: Int = 10,
88+
baseDelayMs: Long = 1000L,
89+
logger: SetupRetryLogger.() -> Unit,
90+
): Result<Unit> = withContext(ioDispatcher) {
91+
val log = SetupRetryLogger().apply(logger)
92+
var attempt = 0
93+
while (attempt < maxAttempts) {
94+
val result = setup()
95+
if (result.isSuccess) {
96+
log.onSuccess(attempt + 1)
97+
return@withContext Result.success(Unit)
98+
}
99+
val exception = result.exceptionOrNull()
100+
if (exception != null && exception !is MnemonicNotAvailableException) {
101+
return@withContext result
102+
}
103+
attempt++
104+
if (attempt < maxAttempts) {
105+
val delayMs = baseDelayMs * attempt
106+
log.onRetry(attempt, maxAttempts, delayMs)
107+
delay(delayMs)
61108
}
62-
}.onFailure {
63-
isSetup.completeExceptionally(it)
64-
Logger.error("VSS client setup error", e = it, context = TAG)
65109
}
110+
log.onExhausted(maxAttempts)
111+
Result.failure(MnemonicNotAvailableException())
66112
}
67113

68114
fun reset() {

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ internal object Env {
5353
get() {
5454
val isE2eLocal = isE2eTest && e2eBackend == "local"
5555
return when (network) {
56-
Network.BITCOIN -> ElectrumServers.MAINNET.FULCRUM
56+
Network.BITCOIN -> ElectrumServers.MAINNET.ESPLORA
5757
Network.REGTEST -> if (isE2eLocal) ElectrumServers.REGTEST.LOCAL else ElectrumServers.REGTEST.STAG
5858
Network.TESTNET -> ElectrumServers.TESTNET
5959
else -> TODO("${network.name} network not implemented")
@@ -211,11 +211,11 @@ object Peers {
211211

212212
private object ElectrumServers {
213213
object MAINNET {
214-
const val FULCRUM = "ssl://fulcrum.bitkit.blocktank.to:8900"
214+
const val ESPLORA = "ssl://bitkit.to:9999"
215215
}
216216

217217
object REGTEST {
218-
const val STAG = "tcp://34.65.252.32:18483"
218+
const val STAG = "ssl://electrs.bitkit.stag0.blocktank.to:9999"
219219
const val LOCAL = "tcp://127.0.0.1:60001"
220220
}
221221

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

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,22 @@ class BackupRepo @Inject constructor(
120120
isObserving = true
121121
Logger.debug("Start observing backup statuses and data store changes", context = TAG)
122122

123-
scope.launch { vssBackupClient.setup() }
123+
scope.launch {
124+
vssBackupClient.setupWithRetry {
125+
onSuccess = { attempt ->
126+
Logger.debug("VSS client setup succeeded on attempt $attempt", context = TAG)
127+
}
128+
onRetry = { attempt, maxAttempts, delayMs ->
129+
Logger.debug(
130+
"VSS client setup deferred, retrying in ${delayMs}ms (attempt $attempt/$maxAttempts)",
131+
context = TAG,
132+
)
133+
}
134+
onExhausted = { maxAttempts ->
135+
Logger.warn("VSS client setup failed after $maxAttempts attempts", context = TAG)
136+
}
137+
}
138+
}
124139

125140
scope.launch {
126141
BackupCategory.entries.forEach { category ->
@@ -543,7 +558,7 @@ class BackupRepo @Inject constructor(
543558
suspend fun getLatestBackupTime(): ULong? = withContext(ioDispatcher) {
544559
runCatching {
545560
withTimeout(VSS_TIMESTAMP_TIMEOUT) {
546-
vssBackupClient.setup()
561+
vssBackupClient.setup().getOrThrow()
547562
coroutineScope {
548563
BackupCategory.entries
549564
.filter { it != BackupCategory.LIGHTNING_CONNECTIONS }
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package to.bitkit.data.backup
2+
3+
import kotlinx.coroutines.runBlocking
4+
import org.junit.Before
5+
import org.junit.Test
6+
import org.mockito.kotlin.any
7+
import org.mockito.kotlin.mock
8+
import org.mockito.kotlin.never
9+
import org.mockito.kotlin.verify
10+
import org.mockito.kotlin.whenever
11+
import to.bitkit.data.keychain.Keychain
12+
import to.bitkit.test.BaseUnitTest
13+
import kotlin.test.assertIs
14+
import kotlin.test.assertTrue
15+
16+
class VssBackupClientTest : BaseUnitTest() {
17+
18+
private lateinit var sut: VssBackupClient
19+
20+
private val vssStoreIdProvider = mock<VssStoreIdProvider>()
21+
private val keychain = mock<Keychain>()
22+
23+
@Before
24+
fun setUp() = runBlocking {
25+
sut = VssBackupClient(
26+
ioDispatcher = testDispatcher,
27+
vssStoreIdProvider = vssStoreIdProvider,
28+
keychain = keychain,
29+
)
30+
}
31+
32+
@Test
33+
fun `setup fails with MnemonicNotAvailableException when mnemonic is not available`() = test {
34+
whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(null)
35+
36+
val result = sut.setup()
37+
38+
assertTrue(result.isFailure)
39+
assertIs<MnemonicNotAvailableException>(result.exceptionOrNull())
40+
}
41+
42+
@Test
43+
fun `setup does not call vssStoreIdProvider when mnemonic is not available`() = test {
44+
whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(null)
45+
46+
sut.setup()
47+
48+
verify(vssStoreIdProvider, never()).getVssStoreId(any())
49+
}
50+
51+
@Test
52+
fun `setup checks mnemonic before proceeding with vss initialization`() = test {
53+
val testMnemonic = "abandon abandon abandon abandon abandon abandon " +
54+
"abandon abandon abandon abandon abandon about"
55+
whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(testMnemonic)
56+
whenever(vssStoreIdProvider.getVssStoreId(any())).thenReturn("test-store-id")
57+
58+
// Setup will fail on native VSS calls, but we verify we passed the mnemonic check
59+
runCatching { sut.setup() }
60+
61+
verify(vssStoreIdProvider).getVssStoreId(any())
62+
}
63+
64+
@Test
65+
fun `setup can be called multiple times when mnemonic not available`() = test {
66+
whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(null)
67+
68+
// Multiple calls should all fail with MnemonicNotAvailableException without crashing
69+
assertIs<MnemonicNotAvailableException>(sut.setup().exceptionOrNull())
70+
assertIs<MnemonicNotAvailableException>(sut.setup().exceptionOrNull())
71+
assertIs<MnemonicNotAvailableException>(sut.setup().exceptionOrNull())
72+
}
73+
}

gradle/libs.versions.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
5858
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
5959
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
6060
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
61-
ldk-node-android = { module = "com.github.synonymdev:ldk-node", version = "v0.7.0-rc.6" } # fork | local: remove `v`
61+
ldk-node-android = { module = "com.github.synonymdev:ldk-node", version = "v0.7.0-rc.8" } # fork | local: remove `v`
6262
lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" }
6363
lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
6464
lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }

0 commit comments

Comments
 (0)