Skip to content
49 changes: 44 additions & 5 deletions app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ import com.synonym.vssclient.vssNewClientWithLnurlAuth
import com.synonym.vssclient.vssStore
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import to.bitkit.data.keychain.Keychain
import to.bitkit.di.IoDispatcher
import to.bitkit.env.Env
import to.bitkit.utils.Logger
import to.bitkit.utils.ServiceError
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds
Expand All @@ -29,8 +29,15 @@ class VssBackupClient @Inject constructor(
) {
private var isSetup = CompletableDeferred<Unit>()

suspend fun setup(walletIndex: Int = 0) = withContext(ioDispatcher) {
suspend fun setup(walletIndex: Int = 0): Result<Boolean> = withContext(ioDispatcher) {
Comment thread
jvsena42 marked this conversation as resolved.
Outdated
Comment thread
jvsena42 marked this conversation as resolved.
Outdated
runCatching {
if (isSetup.isCompleted && !isSetup.isCancelled) {
runCatching { isSetup.await() }.onSuccess { return@runCatching true }
}

val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)
?: return@runCatching false

withTimeout(30.seconds) {
Logger.debug("VSS client setting up…", context = TAG)
val vssUrl = Env.vssServerUrl
Expand All @@ -39,8 +46,6 @@ class VssBackupClient @Inject constructor(
Logger.verbose("Building VSS client with vssUrl: '$vssUrl'", context = TAG)
Logger.verbose("Building VSS client with lnurlAuthServerUrl: '$lnurlAuthServerUrl'", context = TAG)
if (lnurlAuthServerUrl.isNotEmpty()) {
val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)
?: throw ServiceError.MnemonicNotFound()
val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name)

vssNewClientWithLnurlAuth(
Expand All @@ -59,10 +64,44 @@ class VssBackupClient @Inject constructor(
isSetup.complete(Unit)
Logger.info("VSS client setup with server: '$vssUrl'", context = TAG)
}
true
}.onFailure {
isSetup.completeExceptionally(it)
Logger.error("VSS client setup error", e = it, context = TAG)
Logger.error("VSS client setup error", it, TAG)
}
}

class SetupRetryLogger {
var onSuccess: (attempt: Int) -> Unit = {}
var onRetry: (attempt: Int, maxAttempts: Int, delayMs: Long) -> Unit = { _, _, _ -> }
var onExhausted: (maxAttempts: Int) -> Unit = {}
}

suspend fun setupWithRetry(
maxAttempts: Int = 10,
baseDelayMs: Long = 1000L,
logger: SetupRetryLogger.() -> Unit,
): Result<Boolean> = withContext(ioDispatcher) {
Comment thread
jvsena42 marked this conversation as resolved.
Outdated
val log = SetupRetryLogger().apply(logger)
var attempt = 0
while (attempt < maxAttempts) {
val result = setup()
if (result.getOrDefault(false)) {
log.onSuccess(attempt + 1)
return@withContext Result.success(true)
}
if (result.isFailure) {
return@withContext result
}
attempt++
if (attempt < maxAttempts) {
val delayMs = baseDelayMs * attempt
log.onRetry(attempt, maxAttempts, delayMs)
delay(delayMs)
}
}
log.onExhausted(maxAttempts)
Result.success(false)
}

fun reset() {
Expand Down
19 changes: 17 additions & 2 deletions app/src/main/java/to/bitkit/repositories/BackupRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,22 @@ class BackupRepo @Inject constructor(
isObserving = true
Logger.debug("Start observing backup statuses and data store changes", context = TAG)

scope.launch { vssBackupClient.setup() }
scope.launch {
vssBackupClient.setupWithRetry {
onSuccess = { attempt ->
Logger.debug("VSS client setup succeeded on attempt $attempt", context = TAG)
}
onRetry = { attempt, maxAttempts, delayMs ->
Logger.debug(
"VSS client setup deferred, retrying in ${delayMs}ms (attempt $attempt/$maxAttempts)",
context = TAG,
)
}
onExhausted = { maxAttempts ->
Logger.warn("VSS client setup failed after $maxAttempts attempts", context = TAG)
}
}
}

scope.launch {
BackupCategory.entries.forEach { category ->
Expand Down Expand Up @@ -543,7 +558,7 @@ class BackupRepo @Inject constructor(
suspend fun getLatestBackupTime(): ULong? = withContext(ioDispatcher) {
runCatching {
withTimeout(VSS_TIMESTAMP_TIMEOUT) {
vssBackupClient.setup()
vssBackupClient.setup().getOrThrow()
coroutineScope {
BackupCategory.entries
.filter { it != BackupCategory.LIGHTNING_CONNECTIONS }
Expand Down
71 changes: 71 additions & 0 deletions app/src/test/java/to/bitkit/data/backup/VssBackupClientTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package to.bitkit.data.backup

import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Test
import org.mockito.kotlin.any
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import to.bitkit.data.keychain.Keychain
import to.bitkit.test.BaseUnitTest
import kotlin.test.assertFalse

class VssBackupClientTest : BaseUnitTest() {

private lateinit var sut: VssBackupClient

private val vssStoreIdProvider = mock<VssStoreIdProvider>()
private val keychain = mock<Keychain>()

@Before
fun setUp() = runBlocking {
sut = VssBackupClient(
ioDispatcher = testDispatcher,
vssStoreIdProvider = vssStoreIdProvider,
keychain = keychain,
)
}

@Test
fun `setup returns false when mnemonic is not available`() = test {
whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(null)

val result = sut.setup()

assertFalse(result.getOrThrow())
}

@Test
fun `setup does not call vssStoreIdProvider when mnemonic is not available`() = test {
whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(null)

sut.setup()

verify(vssStoreIdProvider, never()).getVssStoreId(any())
}

@Test
fun `setup checks mnemonic before proceeding with vss initialization`() = test {
val testMnemonic = "abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about"
whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(testMnemonic)
whenever(vssStoreIdProvider.getVssStoreId(any())).thenReturn("test-store-id")

// Setup will fail on native VSS calls, but we verify we passed the mnemonic check
runCatching { sut.setup() }

verify(vssStoreIdProvider).getVssStoreId(any())
}

@Test
fun `setup can be called multiple times when mnemonic not available`() = test {
whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(null)

// Multiple calls should all return false without crashing
assertFalse(sut.setup().getOrThrow())
assertFalse(sut.setup().getOrThrow())
assertFalse(sut.setup().getOrThrow())
}
}
Loading