diff --git a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt index 76c74361c..a8a35027e 100644 --- a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt +++ b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt @@ -10,7 +10,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import to.bitkit.data.keychain.Keychain -import to.bitkit.di.BgDispatcher +import to.bitkit.di.IoDispatcher import to.bitkit.env.Env import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError @@ -20,13 +20,13 @@ import kotlin.time.Duration.Companion.seconds @Singleton class VssBackupClient @Inject constructor( - @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val vssStoreIdProvider: VssStoreIdProvider, private val keychain: Keychain, ) { - private val isSetup = CompletableDeferred() + private var isSetup = CompletableDeferred() - suspend fun setup(walletIndex: Int = 0) = withContext(bgDispatcher) { + suspend fun setup(walletIndex: Int = 0) = withContext(ioDispatcher) { try { withTimeout(30.seconds) { Logger.debug("VSS client setting up…", context = TAG) @@ -62,10 +62,18 @@ class VssBackupClient @Inject constructor( } } + fun reset() { + synchronized(this) { + isSetup.cancel() + isSetup = CompletableDeferred() + } + vssStoreIdProvider.clearCache() + Logger.debug("VSS client reset", context = TAG) + } suspend fun putObject( key: String, data: ByteArray, - ): Result = withContext(bgDispatcher) { + ): Result = withContext(ioDispatcher) { isSetup.await() Logger.verbose("VSS 'putObject' call for '$key'", context = TAG) runCatching { @@ -80,7 +88,7 @@ class VssBackupClient @Inject constructor( } } - suspend fun getObject(key: String): Result = withContext(bgDispatcher) { + suspend fun getObject(key: String): Result = withContext(ioDispatcher) { isSetup.await() Logger.verbose("VSS 'getObject' call for '$key'", context = TAG) runCatching { diff --git a/app/src/main/java/to/bitkit/data/backup/VssStoreIdProvider.kt b/app/src/main/java/to/bitkit/data/backup/VssStoreIdProvider.kt index ddcb202e9..a682ed9bc 100644 --- a/app/src/main/java/to/bitkit/data/backup/VssStoreIdProvider.kt +++ b/app/src/main/java/to/bitkit/data/backup/VssStoreIdProvider.kt @@ -34,8 +34,8 @@ class VssStoreIdProvider @Inject constructor( } } - fun clearCache(walletIndex: Int = 0) { - cacheMap.remove(walletIndex) + fun clearCache() { + cacheMap.clear() } companion object { diff --git a/app/src/main/java/to/bitkit/di/DispatchersModule.kt b/app/src/main/java/to/bitkit/di/DispatchersModule.kt index d769f672c..408a9ffe6 100644 --- a/app/src/main/java/to/bitkit/di/DispatchersModule.kt +++ b/app/src/main/java/to/bitkit/di/DispatchersModule.kt @@ -11,15 +11,12 @@ import kotlinx.coroutines.Dispatchers import javax.inject.Qualifier @Qualifier -@Retention(AnnotationRetention.BINARY) annotation class UiDispatcher @Qualifier -@Retention(AnnotationRetention.BINARY) annotation class BgDispatcher @Qualifier -@Retention(AnnotationRetention.BINARY) annotation class IoDispatcher @Module diff --git a/app/src/main/java/to/bitkit/ext/DateTime.kt b/app/src/main/java/to/bitkit/ext/DateTime.kt index c746ba7be..e67045cd1 100644 --- a/app/src/main/java/to/bitkit/ext/DateTime.kt +++ b/app/src/main/java/to/bitkit/ext/DateTime.kt @@ -4,6 +4,7 @@ package to.bitkit.ext import android.icu.text.DateFormat import android.icu.util.ULocale +import kotlinx.datetime.Clock import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.atStartOfDayIn @@ -22,6 +23,8 @@ import java.util.Locale import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.milliseconds +fun nowMillis(clock: Clock = Clock.System): Long = clock.now().toEpochMilliseconds() + fun nowTimestamp(): Instant = Instant.now().truncatedTo(ChronoUnit.SECONDS) fun Instant.formatted(pattern: String = DatePattern.DATE_TIME): String { diff --git a/app/src/main/java/to/bitkit/models/BackupCategory.kt b/app/src/main/java/to/bitkit/models/BackupCategory.kt index 7944ea710..519d85964 100644 --- a/app/src/main/java/to/bitkit/models/BackupCategory.kt +++ b/app/src/main/java/to/bitkit/models/BackupCategory.kt @@ -57,6 +57,8 @@ enum class BackupCategory( @Serializable data class BackupItemStatus( val running: Boolean = false, - val synced: Long = 0L, - val required: Long = 0L, -) + val synced: Long = 0, + val required: Long = 0, +) { + val isRequired: Boolean get() = synced < required +} diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 00a980634..2f40d7116 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -23,9 +23,10 @@ import to.bitkit.data.WidgetsData import to.bitkit.data.WidgetsStore import to.bitkit.data.backup.VssBackupClient import to.bitkit.data.resetPin -import to.bitkit.di.BgDispatcher +import to.bitkit.di.IoDispatcher import to.bitkit.di.json import to.bitkit.ext.formatPlural +import to.bitkit.ext.nowMillis import to.bitkit.models.ActivityBackupV1 import to.bitkit.models.BackupCategory import to.bitkit.models.BackupItemStatus @@ -36,6 +37,7 @@ import to.bitkit.models.WalletBackupV1 import to.bitkit.services.LightningService import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger +import to.bitkit.utils.jsonLogOf import javax.inject.Inject import javax.inject.Singleton @@ -43,7 +45,7 @@ import javax.inject.Singleton @Singleton class BackupRepo @Inject constructor( @ApplicationContext private val context: Context, - @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val cacheStore: CacheStore, private val vssBackupClient: VssBackupClient, private val settingsStore: SettingsStore, @@ -54,7 +56,7 @@ class BackupRepo @Inject constructor( private val clock: Clock, private val db: AppDb, ) { - private val scope = CoroutineScope(bgDispatcher + SupervisorJob()) + private val scope = CoroutineScope(ioDispatcher + SupervisorJob()) private val backupJobs = mutableMapOf() private val statusObserverJobs = mutableListOf() @@ -65,6 +67,11 @@ class BackupRepo @Inject constructor( private var lastNotificationTime = 0L + fun reset() { + stopObservingBackups() + vssBackupClient.reset() + } + fun startObservingBackups() { if (isObserving) return @@ -112,7 +119,7 @@ class BackupRepo @Inject constructor( old.synced == new.synced && old.required == new.required } .collect { status -> - if (status.synced < status.required && !status.running && !isRestoring) { + if (status.isRequired && !status.running && !isRestoring) { scheduleBackup(category) } } @@ -249,12 +256,22 @@ class BackupRepo @Inject constructor( Logger.verbose("Scheduling backup for: '$category'", context = TAG) backupJobs[category] = scope.launch { + // Set running immediately to prevent UI showing failure during debounce + cacheStore.updateBackupStatus(category) { + it.copy(running = true) + } + delay(BACKUP_DEBOUNCE) // Double-check if backup is still needed val status = cacheStore.backupStatuses.first()[category] ?: BackupItemStatus() - if (status.synced < status.required && !status.running && !isRestoring) { + if (status.isRequired && !isRestoring) { triggerBackup(category) + } else { + // Backup no longer needed, reset running flag + cacheStore.updateBackupStatus(category) { + it.copy(running = false) + } } } } @@ -268,7 +285,7 @@ class BackupRepo @Inject constructor( val hasFailedBackups = BackupCategory.entries.any { category -> val status = backupStatuses[category] ?: BackupItemStatus() - val isPendingAndOverdue = status.synced < status.required && + val isPendingAndOverdue = status.isRequired && currentTime - status.required > FAILED_BACKUP_CHECK_TIME return@any isPendingAndOverdue } @@ -290,13 +307,13 @@ class BackupRepo @Inject constructor( type = Toast.ToastType.ERROR, title = context.getString(R.string.settings__backup__failed_title), description = context.getString(R.string.settings__backup__failed_message).formatPlural( - mapOf("interval" to (BACKUP_CHECK_INTERVAL / 60000)) // displayed in minutes + mapOf("interval" to (BACKUP_CHECK_INTERVAL / MINUTE_IN_MS)) ), ) } } - suspend fun triggerBackup(category: BackupCategory) = withContext(bgDispatcher) { + suspend fun triggerBackup(category: BackupCategory) = withContext(ioDispatcher) { Logger.debug("Backup starting for: '$category'", context = TAG) cacheStore.updateBackupStatus(category) { @@ -385,12 +402,23 @@ class BackupRepo @Inject constructor( BackupCategory.LIGHTNING_CONNECTIONS -> throw NotImplementedError("LIGHTNING backup is managed by ldk-node") } - suspend fun performFullRestoreFromLatestBackup(): Result = withContext(bgDispatcher) { + suspend fun performFullRestoreFromLatestBackup( + onCacheRestored: suspend () -> Unit = {}, + ): Result = withContext(ioDispatcher) { Logger.debug("Full restore starting", context = TAG) isRestoring = true return@withContext try { + performRestore(BackupCategory.METADATA) { dataBytes -> + val parsed = json.decodeFromString(String(dataBytes)) + cacheStore.update { parsed.cache } + Logger.debug("Restored caches: ${jsonLogOf(parsed.cache.copy(cachedRates = emptyList()))}", TAG) + onCacheRestored() + db.tagMetadataDao().upsert(parsed.tagMetadata) + Logger.debug("Restored caches and ${parsed.tagMetadata.size} tags metadata records", TAG) + } + performRestore(BackupCategory.SETTINGS) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)).resetPin() settingsStore.update { parsed } @@ -404,12 +432,6 @@ class BackupRepo @Inject constructor( db.transferDao().upsert(parsed.transfers) Logger.debug("Restored ${parsed.transfers.size} transfers", context = TAG) } - performRestore(BackupCategory.METADATA) { dataBytes -> - val parsed = json.decodeFromString(String(dataBytes)) - db.tagMetadataDao().upsert(parsed.tagMetadata) - cacheStore.update { parsed.cache } - Logger.debug("Restored caches and ${parsed.tagMetadata.size} tags metadata records", TAG) - } performRestore(BackupCategory.BLOCKTANK) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)) blocktankRepo.restoreFromBackup(parsed).onSuccess { @@ -436,6 +458,15 @@ class BackupRepo @Inject constructor( } } + fun scheduleFullBackup() { + Logger.debug("Scheduling backups for all categories", context = TAG) + BackupCategory.entries + .filter { it != BackupCategory.LIGHTNING_CONNECTIONS } + .forEach { + scheduleBackup(it) + } + } + private suspend fun performRestore( category: BackupCategory, restoreAction: suspend (ByteArray) -> Unit, @@ -453,16 +484,18 @@ class BackupRepo @Inject constructor( Logger.debug("Restore error for: '$category'", context = TAG) } + val now = currentTimeMillis() cacheStore.updateBackupStatus(category) { - it.copy(running = false, synced = currentTimeMillis()) + it.copy(running = false, synced = now, required = now) } } - private fun currentTimeMillis(): Long = clock.now().toEpochMilliseconds() + private fun currentTimeMillis(): Long = nowMillis(clock) companion object { private const val TAG = "BackupRepo" + private const val MINUTE_IN_MS = 60_000 private const val BACKUP_DEBOUNCE = 5000L // 5 seconds private const val BACKUP_CHECK_INTERVAL = 60 * 1000L // 1 minute private const val FAILED_BACKUP_CHECK_TIME = 30 * 60 * 1000L // 30 minutes diff --git a/app/src/main/java/to/bitkit/repositories/HealthRepo.kt b/app/src/main/java/to/bitkit/repositories/HealthRepo.kt index 67a2cf445..72b9a211d 100644 --- a/app/src/main/java/to/bitkit/repositories/HealthRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HealthRepo.kt @@ -126,7 +126,7 @@ class HealthRepo @Inject constructor( val now = clock.now().toEpochMilliseconds() fun isSyncOk(synced: Long, required: Long) = - synced > required || (now - required) < 5.minutes.inWholeMilliseconds + synced >= required || (now - required) < 5.minutes.inWholeMilliseconds val isBackupSyncOk = BackupCategory.entries .filter { it != BackupCategory.LIGHTNING_CONNECTIONS } diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 3d23b23d3..b8fb5db3b 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -52,6 +52,7 @@ class WalletRepo @Inject constructor( private val cacheStore: CacheStore, private val deriveBalanceStateUseCase: DeriveBalanceStateUseCase, private val vssStoreIdProvider: VssStoreIdProvider, + private val backupRepo: BackupRepo, ) { private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob()) @@ -61,11 +62,6 @@ class WalletRepo @Inject constructor( private val _balanceState = MutableStateFlow(BalanceState()) val balanceState = _balanceState.asStateFlow() - init { - // Load from cache once on init - loadFromCache() - } - fun loadFromCache() { // TODO try keeping in sync with cache if performant and reliable repoScope.launch { @@ -243,15 +239,17 @@ class WalletRepo @Inject constructor( suspend fun wipeWallet(walletIndex: Int = 0): Result = withContext(bgDispatcher) { try { + backupRepo.reset() + + _walletState.update { WalletState() } + _balanceState.update { BalanceState() } + keychain.wipe() - vssStoreIdProvider.clearCache(walletIndex) db.clearAllTables() settingsStore.reset() cacheStore.reset() // TODO CLEAN ACTIVITY'S AND UPDATE STATE. CHECK ActivityListViewModel.removeAllActivities coreService.activity.removeAll() - _walletState.update { WalletState() } - _balanceState.update { BalanceState() } setWalletExistsState() return@withContext lightningRepo.wipeStorage(walletIndex = walletIndex) diff --git a/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt index c14ec3ac2..bf4a1e942 100644 --- a/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt @@ -91,7 +91,7 @@ private fun BackupSettingsScreenContent( onBack: () -> Unit, onClose: () -> Unit, ) { - val allSynced = uiState.categories.all { it.status.synced >= it.status.required } + val allSynced = uiState.categories.all { !it.status.isRequired } ScreenColumn { AppTopBar( titleText = stringResource(R.string.settings__backup__title), @@ -158,7 +158,7 @@ private fun BackupStatusItem( val subtitle = when { status.running -> "Running" // TODO add missing localized text - status.synced >= status.required -> stringResource(R.string.settings__backup__status_success) + !status.isRequired -> stringResource(R.string.settings__backup__status_success) .replace("{time}", status.synced.toLocalizedTimestamp()) else -> stringResource(R.string.settings__backup__status_failed) @@ -182,7 +182,7 @@ private fun BackupStatusItem( CaptionB(text = subtitle, color = Colors.White64, maxLines = 1) } - val showRetry = !uiState.disableRetry && !status.running && status.synced < status.required + val showRetry = !uiState.disableRetry && !status.running && status.isRequired if (showRetry) { BackupRetryButton( onClick = { onRetryClick(uiState.category) }, @@ -204,7 +204,7 @@ private fun BackupStatusIcon( .background( color = when { status.running -> Colors.Yellow16 - status.synced >= status.required -> Colors.Green16 + !status.isRequired -> Colors.Green16 else -> Colors.Red16 }, shape = CircleShape @@ -215,7 +215,7 @@ private fun BackupStatusIcon( contentDescription = null, tint = when { status.running -> Colors.Yellow - status.synced >= status.required -> Colors.Green + !status.isRequired -> Colors.Green else -> Colors.Red }, modifier = Modifier.size(16.dp) @@ -251,7 +251,6 @@ private fun Preview() { val timestamp = System.currentTimeMillis() - (minutesAgo * 60 * 1000) when (it.category) { - BackupCategory.LIGHTNING_CONNECTIONS -> it.copy(disableRetry = true) BackupCategory.WALLET -> it.copy(status = BackupItemStatus(running = true, required = 1)) BackupCategory.METADATA -> it.copy(status = BackupItemStatus(required = 1)) else -> it.copy(status = BackupItemStatus(synced = timestamp, required = timestamp)) diff --git a/app/src/main/java/to/bitkit/viewmodels/BackupsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/BackupsViewModel.kt index 5abe7ad31..6ef0f4cd2 100644 --- a/app/src/main/java/to/bitkit/viewmodels/BackupsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/BackupsViewModel.kt @@ -35,12 +35,7 @@ class BackupsViewModel @Inject constructor( cacheStore.backupStatuses.collect { cachedStatuses -> val categories = BackupCategory.entries.map { category -> val cachedStatus = cachedStatuses[category] ?: BackupItemStatus(synced = 0, required = 1) - category.toUiState(cachedStatus).let { uiState -> - when (category) { - BackupCategory.LIGHTNING_CONNECTIONS -> uiState.copy(disableRetry = true) - else -> uiState - } - } + category.toUiState(cachedStatus) } _uiState.update { it.copy(categories = categories) } } @@ -85,5 +80,6 @@ fun BackupCategory.toUiState(status: BackupItemStatus = BackupItemStatus()): Bac return BackupCategoryUiState( category = this, status = status, + disableRetry = this == BackupCategory.LIGHTNING_CONNECTIONS, ) } diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 37a69d1f5..564f47d73 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -69,6 +69,9 @@ class WalletViewModel @Inject constructor( private fun walletEffect(effect: WalletViewModelEffects) = viewModelScope.launch { _walletEffect.emit(effect) } init { + if (walletExists) { + walletRepo.loadFromCache() + } collectStates() } @@ -88,7 +91,7 @@ class WalletViewModel @Inject constructor( ) } if (state.walletExists && restoreState == RestoreState.RestoringWallet) { - triggerBackupRestore() + restoreFromBackup() } } } @@ -108,14 +111,11 @@ class WalletViewModel @Inject constructor( } } - private fun triggerBackupRestore() { + private suspend fun restoreFromBackup() { restoreState = RestoreState.RestoringBackups - - viewModelScope.launch { - backupRepo.performFullRestoreFromLatestBackup() - // data backup is not critical and mostly for user convenience so there is no reason to propagate errors up - restoreState = RestoreState.BackupRestoreCompleted - } + backupRepo.performFullRestoreFromLatestBackup(onCacheRestored = walletRepo::loadFromCache) + // data backup is not critical and mostly for user convenience so there is no reason to propagate errors up + restoreState = RestoreState.BackupRestoreCompleted } fun setRestoringWalletState() { @@ -245,9 +245,13 @@ class WalletViewModel @Inject constructor( } suspend fun createWallet(bip39Passphrase: String?) { - walletRepo.createWallet(bip39Passphrase).onFailure { error -> - ToastEventBus.send(error) - } + walletRepo.createWallet(bip39Passphrase) + .onSuccess { + backupRepo.scheduleFullBackup() + } + .onFailure { error -> + ToastEventBus.send(error) + } } suspend fun restoreWallet(mnemonic: String, bip39Passphrase: String?) { diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index cfb0835b6..cf4375ddf 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -48,6 +48,7 @@ class WalletRepoTest : BaseUnitTest() { private val cacheStore: CacheStore = mock() private val deriveBalanceStateUseCase: DeriveBalanceStateUseCase = mock() private val vssStoreIdProvider = mock() + private val backupRepo = mock() @Before fun setUp() { @@ -78,6 +79,7 @@ class WalletRepoTest : BaseUnitTest() { cacheStore = cacheStore, deriveBalanceStateUseCase = deriveBalanceStateUseCase, vssStoreIdProvider = vssStoreIdProvider, + backupRepo = backupRepo, ) @Test @@ -196,6 +198,7 @@ class WalletRepoTest : BaseUnitTest() { whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = existingAddress))) whenever(addressChecker.getAddressInfo(any())).thenReturn(mockAddressInfo()) sut = createSut() + sut.loadFromCache() val result = sut.refreshBip21() @@ -266,6 +269,7 @@ class WalletRepoTest : BaseUnitTest() { whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = testAddress))) whenever(lightningRepo.createInvoice(anyOrNull(), any(), any())).thenReturn(Result.success("testInvoice")) sut = createSut() + sut.loadFromCache() sut.updateBip21Invoice(amountSats = 1000uL, description = "test").let { result -> assertTrue(result.isSuccess) @@ -524,6 +528,7 @@ class WalletRepoTest : BaseUnitTest() { val testAddress = "testAddress" whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = testAddress))) sut = createSut() + sut.loadFromCache() sut.setBolt11("existingInvoice") whenever(lightningRepo.canReceive()).thenReturn(false) @@ -570,6 +575,7 @@ class WalletRepoTest : BaseUnitTest() { ) whenever(lightningRepo.newAddress()).thenReturn(Result.success("newAddress")) sut = createSut() + sut.loadFromCache() sut.refreshBip21ForEvent( Event.PaymentReceived( @@ -589,6 +595,7 @@ class WalletRepoTest : BaseUnitTest() { whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = testAddress))) whenever(addressChecker.getAddressInfo(any())).thenReturn(mockAddressInfo()) sut = createSut() + sut.loadFromCache() sut.refreshBip21ForEvent( Event.PaymentReceived( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2f9c7e81e..4158f5461 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,7 +44,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version.ref appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "barcodeScanning" } biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } -bitkitcore = { module = "com.synonym:bitkit-core-android", version = "0.1.22" } +bitkitcore = { module = "com.synonym:bitkit-core-android", version = "0.1.23" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncyCastle" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" }