diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 318e863e7..4450eab5b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -46,7 +46,7 @@ android { applicationId = "to.bitkit" minSdk = 28 targetSdk = 36 - versionCode = 17 + versionCode = 162 versionName = "0.0.17" testInstrumentationRunner = "to.bitkit.test.HiltTestRunner" vectorDrawables { diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 4c22dcc49..30e815741 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -188,6 +188,18 @@ internal object Env { const val BITREFILL_APP = "Bitkit" const val BITREFILL_REF = "AL6dyZYt" + val rnBackupServerHost: String + get() = when (network) { + Network.BITCOIN -> "https://blocktank.synonym.to/backups-ldk" + else -> "https://bitkit.stag0.blocktank.to/backups-ldk" + } + + val rnBackupServerPubKey: String + get() = when (network) { + Network.BITCOIN -> "0236efd76e37f96cf2dced9d52ff84c97e5b3d4a75e7d494807291971783f38377" + else -> "02c03b8b8c1b5500b622646867d99bf91676fac0f38e2182c91a9ff0d053a21d6d" + } + // endregion } diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 3ff85ddc5..e3df92524 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -110,11 +110,14 @@ class ActivityRepo @Inject constructor( /** * Syncs `ldk-node` [PaymentDetails] list to `bitkit-core` [Activity] items. */ - private suspend fun syncLdkNodePayments(payments: List): Result = runCatching { - val channelIdsByTxId = findChannelsForPayments(payments) - coreService.activity.syncLdkNodePaymentsToActivities(payments, channelIdsByTxId = channelIdsByTxId) - }.onFailure { e -> - Logger.error("Error syncing LDK payments:", e, context = TAG) + suspend fun syncLdkNodePayments(payments: List): Result = withContext(bgDispatcher) { + return@withContext runCatching { + val channelIdsByTxId = findChannelsForPayments(payments) + coreService.activity.syncLdkNodePaymentsToActivities(payments, channelIdsByTxId = channelIdsByTxId) + notifyActivitiesChanged() + }.onFailure { e -> + Logger.error("Error syncing LDK payments:", e, context = TAG) + } } private suspend fun findChannelsForPayments( @@ -666,6 +669,15 @@ class ActivityRepo @Inject constructor( } } + suspend fun markAllUnseenActivitiesAsSeen(): Result = withContext(bgDispatcher) { + return@withContext runCatching { + coreService.activity.markAllUnseenActivitiesAsSeen() + notifyActivitiesChanged() + }.onFailure { e -> + Logger.error("Failed to mark all activities as seen: $e", e, context = TAG) + } + } + // MARK: - Development/Testing Methods /** diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index b3e147b04..14fcb556b 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -7,6 +7,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -21,6 +23,8 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.Serializable import to.bitkit.R import to.bitkit.data.AppDb import to.bitkit.data.CacheStore @@ -49,6 +53,7 @@ import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Clock +import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime /** @@ -536,6 +541,37 @@ class BackupRepo @Inject constructor( } } + suspend fun getLatestBackupTime(): ULong? = withContext(ioDispatcher) { + runCatching { + withTimeout(VSS_TIMESTAMP_TIMEOUT) { + vssBackupClient.setup() + coroutineScope { + BackupCategory.entries + .filter { it != BackupCategory.LIGHTNING_CONNECTIONS } + .map { category -> async { getRemoteBackupTimestamp(category) } } + .mapNotNull { it.await() } + .filter { it > 0uL } + .maxOrNull() + } + } + }.onFailure { e -> + Logger.warn("Failed to get VSS backup timestamp: $e", context = TAG) + }.getOrNull() + } + + private suspend fun getRemoteBackupTimestamp(category: BackupCategory): ULong? { + val item = vssBackupClient.getObject(category.name).getOrNull() ?: return null + val data = item.value ?: return null + + @Serializable + data class BackupWithCreatedAt(val createdAt: Long? = null) + + return runCatching { + val millis = json.decodeFromString(String(data)).createdAt ?: return@runCatching null + (millis / 1000).toULong() + }.getOrNull() + } + fun scheduleFullBackup() { Logger.debug("Scheduling backups for all categories", context = TAG) BackupCategory.entries @@ -578,5 +614,6 @@ class BackupRepo @Inject constructor( private const val FAILED_BACKUP_CHECK_TIME = 30 * 60 * 1000L // 30 minutes private const val FAILED_BACKUP_NOTIFICATION_INTERVAL = 10 * 60 * 1000L // 10 minutes private const val SYNC_STATUS_DEBOUNCE = 500L // 500ms debounce for sync status updates + private val VSS_TIMESTAMP_TIMEOUT = 60.seconds } } diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 5b5140a38..2b350d488 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -29,6 +29,7 @@ import org.lightningdevkit.ldknode.Address import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.BestBlock import org.lightningdevkit.ldknode.ChannelConfig +import org.lightningdevkit.ldknode.ChannelDataMigration import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.ClosureReason import org.lightningdevkit.ldknode.Event @@ -167,10 +168,11 @@ class LightningRepo @Inject constructor( walletIndex: Int, customServerUrl: String? = null, customRgsServerUrl: String? = null, + channelMigration: ChannelDataMigration? = null, ) = withContext(bgDispatcher) { return@withContext try { val trustedPeers = getTrustedPeersFromBlocktank() - lightningService.setup(walletIndex, customServerUrl, customRgsServerUrl, trustedPeers) + lightningService.setup(walletIndex, customServerUrl, customRgsServerUrl, trustedPeers, channelMigration) Result.success(Unit) } catch (e: Throwable) { Logger.error("Node setup error", e, context = TAG) @@ -196,6 +198,7 @@ class LightningRepo @Inject constructor( customServerUrl: String? = null, customRgsServerUrl: String? = null, eventHandler: NodeEventHandler? = null, + channelMigration: ChannelDataMigration? = null, ): Result = withContext(bgDispatcher) { if (_isRecoveryMode.value) { return@withContext Result.failure(RecoveryModeException()) @@ -214,7 +217,7 @@ class LightningRepo @Inject constructor( // Setup if needed if (lightningService.node == null) { - val setupResult = setup(walletIndex, customServerUrl, customRgsServerUrl) + val setupResult = setup(walletIndex, customServerUrl, customRgsServerUrl, channelMigration) if (setupResult.isFailure) { _lightningState.update { it.copy( @@ -264,6 +267,7 @@ class LightningRepo @Inject constructor( shouldRetry = false, customServerUrl = customServerUrl, customRgsServerUrl = customRgsServerUrl, + channelMigration = channelMigration, ) } else { Logger.error("Node start error", e, context = TAG) @@ -311,6 +315,19 @@ class LightningRepo @Inject constructor( } } + suspend fun restart(): Result = withContext(bgDispatcher) { + stop().onFailure { + Logger.error("Failed to stop node during restart", it, context = TAG) + return@withContext Result.failure(it) + } + delay(500) + start(shouldRetry = false).onFailure { + Logger.error("Failed to start node during restart", it, context = TAG) + return@withContext Result.failure(it) + } + Result.success(Unit) + } + suspend fun sync(): Result = executeWhenNodeRunning("sync") { // If sync is in progress, mark pending and skip if (!syncMutex.tryLock()) { diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 3ef3bb3df..441357454 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -586,19 +586,15 @@ class ActivityService( timestamp: ULong, ): ConfirmationData { var isConfirmed = false - var confirmedTimestamp: ULong? = null + var blockTimestamp: ULong? = null val status = kind.status if (status is ConfirmationStatus.Confirmed) { isConfirmed = true - confirmedTimestamp = status.timestamp + blockTimestamp = status.timestamp } - if (isConfirmed && confirmedTimestamp != null && confirmedTimestamp < timestamp) { - confirmedTimestamp = timestamp - } - - return ConfirmationData(isConfirmed, confirmedTimestamp, timestamp) + return ConfirmationData(isConfirmed, blockTimestamp, timestamp) } private fun buildUpdatedOnchainActivity( @@ -636,6 +632,14 @@ class ActivityService( channelId: String? = null, ): OnchainActivity { val isTransfer = channelId != null + val paymentTimestamp = confirmationData.timestamp + val blockTimestamp = confirmationData.confirmedTimestamp + + val activityTimestamp = if (blockTimestamp != null && blockTimestamp < paymentTimestamp) { + blockTimestamp + } else { + paymentTimestamp + } return OnchainActivity.create( id = payment.id, @@ -644,10 +648,10 @@ class ActivityService( value = payment.amountSats ?: 0u, fee = (payment.feePaidMsat ?: 0u) / 1000u, address = resolvedAddress ?: "Loading...", - timestamp = confirmationData.timestamp, + timestamp = activityTimestamp, confirmed = confirmationData.isConfirmed, isTransfer = isTransfer, - confirmTimestamp = confirmationData.confirmedTimestamp, + confirmTimestamp = blockTimestamp, channelId = channelId, seenAt = null, ) @@ -1112,13 +1116,42 @@ class ActivityService( markActivityAsSeen(activity.id, seenAt) } + suspend fun markAllUnseenActivitiesAsSeen() = ServiceQueue.CORE.background { + val timestamp = (System.currentTimeMillis() / 1000).toULong() + val activities = getActivities( + filter = ActivityFilter.ALL, + txType = null, + tags = null, + search = null, + minDate = null, + maxDate = null, + limit = null, + sortDirection = null, + ) + + for (activity in activities) { + val isSeen = when (activity) { + is Activity.Onchain -> activity.v1.seenAt != null + is Activity.Lightning -> activity.v1.seenAt != null + } + + if (!isSeen) { + val activityId = when (activity) { + is Activity.Onchain -> activity.v1.id + is Activity.Lightning -> activity.v1.id + } + markActivityAsSeen(activityId, timestamp) + } + } + } + suspend fun getBoostTxDoesExist(boostTxIds: List): Map { return ServiceQueue.CORE.background { val doesExistMap = mutableMapOf() for (boostTxId in boostTxIds) { val boostActivity = getOnchainActivityByTxId(boostTxId) if (boostActivity != null) { - doesExistMap[boostTxId] = boostActivity.doesExist + doesExistMap[boostTxId] = boostActivity.doesExist && !boostActivity.isBoosted } } return@background doesExistMap diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 4bd520d90..ecf919925 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -18,6 +18,7 @@ import org.lightningdevkit.ldknode.Bolt11InvoiceDescription import org.lightningdevkit.ldknode.BuildException import org.lightningdevkit.ldknode.Builder import org.lightningdevkit.ldknode.ChannelConfig +import org.lightningdevkit.ldknode.ChannelDataMigration import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.CoinSelectionAlgorithm import org.lightningdevkit.ldknode.Config @@ -79,6 +80,7 @@ class LightningService @Inject constructor( customServerUrl: String? = null, customRgsServerUrl: String? = null, trustedPeers: List? = null, + channelMigration: ChannelDataMigration? = null, ) { Logger.debug("Building node…") @@ -88,6 +90,7 @@ class LightningService @Inject constructor( customServerUrl, customRgsServerUrl, config, + channelMigration, ) Logger.info("LDK node setup") @@ -123,11 +126,21 @@ class LightningService @Inject constructor( customServerUrl: String?, customRgsServerUrl: String?, config: Config, + channelMigration: ChannelDataMigration? = null, ): Node = ServiceQueue.LDK.background { val builder = Builder.fromConfig(config).apply { setCustomLogger(LdkLogWriter()) configureChainSource(customServerUrl) configureGossipSource(customRgsServerUrl) + + if (channelMigration != null) { + setChannelDataMigration(channelMigration) + Logger.info( + "Applied channel migration: ${channelMigration.channelMonitors.size} monitors", + context = "Migration" + ) + } + setEntropyBip39Mnemonic( mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound, passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name), diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index 0a58284de..b9e108fb2 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -1,118 +1,1702 @@ package to.bitkit.services -import android.content.ContentValues import android.content.Context -import android.database.sqlite.SQLiteDatabase -import android.database.sqlite.SQLiteOpenHelper +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.ActivityTags +import com.synonym.bitkitcore.ClosedChannelDetails +import com.synonym.bitkitcore.LightningActivity +import com.synonym.bitkitcore.OnchainActivity +import com.synonym.bitkitcore.PaymentState +import com.synonym.bitkitcore.PaymentType import dagger.hilt.android.qualifiers.ApplicationContext -import org.ldk.structs.KeysManager +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import to.bitkit.data.SettingsStore +import to.bitkit.data.WidgetsStore +import to.bitkit.data.keychain.Keychain +import to.bitkit.data.resetPin +import to.bitkit.di.json import to.bitkit.env.Env -import to.bitkit.ext.toHex +import to.bitkit.models.BitcoinDisplayUnit +import to.bitkit.models.CoinSelectionPreference +import to.bitkit.models.PrimaryDisplay +import to.bitkit.models.TransactionSpeed +import to.bitkit.models.WidgetType +import to.bitkit.models.WidgetWithPosition +import to.bitkit.repositories.ActivityRepo +import to.bitkit.services.core.Bip39Service import to.bitkit.utils.Logger -import to.bitkit.utils.ServiceError import java.io.File +import java.security.KeyStore import javax.inject.Inject -import kotlin.io.path.Path -import org.ldk.structs.Result_C2Tuple_ThirtyTwoBytesChannelMonitorZDecodeErrorZ.Result_C2Tuple_ThirtyTwoBytesChannelMonitorZDecodeErrorZ_OK as ChannelMonitorDecodeResultTuple -import org.ldk.structs.UtilMethods.C2Tuple_ThirtyTwoBytesChannelMonitorZ_read as read32BytesChannelMonitor +import javax.inject.Singleton +private val Context.rnMigrationDataStore: DataStore by preferencesDataStore( + name = "rn_migration" +) + +private val Context.rnKeychainDataStore: DataStore by preferencesDataStore( + name = "RN_KEYCHAIN" +) + +@Serializable +private data class RNRemoteActivityItem( + val id: String, + val activityType: String, + val txType: String, + val txId: String? = null, + val value: Long, + val fee: Long? = null, + val feeRate: Long? = null, + val address: String? = null, + val confirmed: Boolean? = null, + val timestamp: Long, + val isBoosted: Boolean? = null, + val isTransfer: Boolean? = null, + val exists: Boolean? = null, + val confirmTimestamp: Long? = null, + val channelId: String? = null, + val transferTxId: String? = null, + val status: String? = null, + val message: String? = null, + val preimage: String? = null, + val boostedParents: List? = null, +) + +@Serializable +private data class RNRemoteWalletBackup( + val transfers: Map>? = null, + val boostedTransactions: Map>? = null, +) + +@Serializable +private data class RNRemoteTransfer(val txId: String? = null, val type: String? = null) + +@Serializable +private data class RNRemoteBoostedTx( + val oldTxId: String? = null, + val newTxId: String? = null, + val childTransaction: String? = null, +) + +@Serializable +private data class RNRemoteBlocktankBackup( + val orders: List? = null, + val paidOrders: List? = null, +) + +@Serializable +private data class RNRemoteBlocktankOrder( + val id: String, + val state: String? = null, + val lspBalanceSat: ULong? = null, + val clientBalanceSat: ULong? = null, + val channelExpiryWeeks: Int? = null, + val createdAt: String? = null, +) + +@Suppress("LargeClass", "TooManyFunctions") +@Singleton class MigrationService @Inject constructor( @ApplicationContext private val context: Context, + private val keychain: Keychain, + private val settingsStore: SettingsStore, + private val widgetsStore: WidgetsStore, + private val activityRepo: ActivityRepo, + private val coreService: CoreService, + private val rnBackupClient: RNBackupClient, + private val bip39Service: Bip39Service, ) { - fun migrate(seed: ByteArray, manager: ByteArray, monitors: List) { - Logger.debug("Migrating LDK backup…") + companion object { + private const val TAG = "Migration" + const val RN_MIGRATION_COMPLETED_KEY = "rnMigrationCompleted" + const val RN_MIGRATION_CHECKED_KEY = "rnMigrationChecked" + private const val RN_WALLET_NAME = "wallet0" + private const val MILLISECONDS_TO_SECONDS = 1000 + private const val GCM_IV_LENGTH = 12 + private const val GCM_TAG_LENGTH = 128 + } + + private val rnMigrationStore = context.rnMigrationDataStore + + private inline fun decodeBackupData(data: ByteArray): T { + val jsonElement = json.parseToJsonElement(String(data)) + val dataElement = jsonElement.jsonObject["data"] ?: error("Missing 'data' field") + return json.decodeFromJsonElement(dataElement) + } + + private val _isShowingMigrationLoading = MutableStateFlow(false) + val isShowingMigrationLoading: StateFlow = _isShowingMigrationLoading.asStateFlow() + + fun setShowingMigrationLoading(value: Boolean) { + _isShowingMigrationLoading.update { value } + } + + private val _isRestoringFromRNRemoteBackup = MutableStateFlow(false) + val isRestoringFromRNRemoteBackup: StateFlow = _isRestoringFromRNRemoteBackup.asStateFlow() + + fun setRestoringFromRNRemoteBackup(value: Boolean) { + _isRestoringFromRNRemoteBackup.update { value } + } + + @Volatile + private var pendingChannelMigration: PendingChannelMigration? = null + + fun consumePendingChannelMigration(): PendingChannelMigration? { + val migration = pendingChannelMigration ?: return null + pendingChannelMigration = null + return migration + } + + fun peekPendingChannelMigration(): PendingChannelMigration? { + return pendingChannelMigration + } + + @Volatile + private var pendingRemoteActivityData: List? = null + + @Volatile + private var pendingRemoteTransfers: Map? = null + + @Volatile + private var pendingRemoteBoosts: Map? = null + + @Volatile + private var pendingRemoteMetadata: RNMetadata? = null + + private val rnNetworkString: String + get() = when (Env.network) { + org.lightningdevkit.ldknode.Network.BITCOIN -> "bitcoin" + org.lightningdevkit.ldknode.Network.REGTEST -> "bitcoinRegtest" + org.lightningdevkit.ldknode.Network.TESTNET -> "bitcoinTestnet" + org.lightningdevkit.ldknode.Network.SIGNET -> "signet" + } + + private val rnLdkBasePath: File + get() = File(context.filesDir, "ldk") + + private val rnLdkAccountPath: File + get() { + val accountName = buildString { + append(RN_WALLET_NAME) + append(rnNetworkString) + append("ldkaccountv3") + } + return File(rnLdkBasePath, accountName) + } + + private val rnMmkvPath: File + get() = File(context.filesDir, "mmkv/mmkv.default") + + suspend fun isMigrationChecked(): Boolean { + val key = stringPreferencesKey(RN_MIGRATION_CHECKED_KEY) + return rnMigrationStore.data.first()[key] == "true" + } + + suspend fun isMigrationCompleted(): Boolean { + val key = stringPreferencesKey(RN_MIGRATION_COMPLETED_KEY) + return rnMigrationStore.data.first()[key] == "true" + } + + suspend fun markMigrationChecked() { + val key = stringPreferencesKey(RN_MIGRATION_CHECKED_KEY) + rnMigrationStore.edit { it[key] = "true" } + } + + suspend fun hasRNWalletData(): Boolean { + val mnemonic = loadStringFromRNKeychain(RNKeychainKey.MNEMONIC) + if (mnemonic?.isNotEmpty() == true) { + return true + } + + return hasRNMmkvData() || hasRNLdkData() + } + + @Suppress("TooGenericExceptionCaught", "SwallowedException") + suspend fun hasNativeWalletData(): Boolean { + return try { + keychain.exists(Keychain.Key.BIP39_MNEMONIC.name) + } catch (e: Exception) { + false + } + } - val file = Path(Env.ldkStoragePath(0), LDK_DB_NAME).toFile() + suspend fun hasRNLdkData(): Boolean { + return File(rnLdkAccountPath, "channel_manager.bin").exists() + } + + suspend fun hasRNMmkvData(): Boolean { + return rnMmkvPath.exists() + } + + @Suppress("TooGenericExceptionCaught") + private suspend fun loadStringFromRNKeychain(key: RNKeychainKey): String? { + val datastorePath = File(context.filesDir, "datastore/RN_KEYCHAIN.preferences_pb") + if (!datastorePath.exists()) { + return null + } + + return try { + val preferences = context.rnKeychainDataStore.data.first() + + val passwordKey = stringPreferencesKey("${key.service}:p") + val cipherKey = stringPreferencesKey("${key.service}:c") + + val encryptedValue = preferences[passwordKey] ?: return null + val cipherInfo = preferences[cipherKey] + + val fullEncryptedValue = if (cipherInfo != null && !encryptedValue.contains(":")) { + "$cipherInfo:$encryptedValue" + } else { + encryptedValue + } + decryptRNKeychainValue(fullEncryptedValue, key.service) + } catch (e: Exception) { + Logger.error("Error reading from RN_KEYCHAIN DataStore: $e", e, context = TAG) + null + } + } + + @Suppress("TooGenericExceptionCaught") + private fun decryptRNKeychainValue(encryptedValue: String, service: String): String? { + if (!encryptedValue.contains(":")) { + return try { + val encryptedBytes = android.util.Base64.decode(encryptedValue, android.util.Base64.DEFAULT) + decryptWithKeystore(encryptedBytes, service) + } catch (e: Exception) { + Logger.error("Failed to decrypt without cipher prefix: $e", e, context = TAG) + null + } + } + + val parts = encryptedValue.split(":", limit = 2) + val cipherName = parts[0] + val encryptedDataBase64 = parts[1] + + if (!cipherName.contains("KeystoreAESGCM")) { + Logger.warn("Unsupported cipher: $cipherName. Only KeystoreAESGCM is supported.", context = TAG) + return null + } + + return try { + val encryptedBytes = android.util.Base64.decode(encryptedDataBase64, android.util.Base64.DEFAULT) + decryptWithKeystore(encryptedBytes, service) + } catch (e: Exception) { + Logger.error("Failed to decrypt RN keychain value: $e", e, context = TAG) + null + } + } + + @Suppress("TooGenericExceptionCaught") + private fun decryptWithKeystore(encryptedBytes: ByteArray, service: String): String? { + if (encryptedBytes.size < GCM_IV_LENGTH) { + Logger.error("Encrypted data too short: ${encryptedBytes.size} bytes", context = TAG) + return null + } + + val iv = encryptedBytes.sliceArray(0 until GCM_IV_LENGTH) + val ciphertext = encryptedBytes.sliceArray(GCM_IV_LENGTH until encryptedBytes.size) - // Skip if db already exists - if (file.exists()) { - throw ServiceError.LdkNodeSqliteAlreadyExists(file.path) + return try { + val keystore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + + val alias = service + if (!keystore.containsAlias(alias)) { + Logger.error("Keystore alias '$alias' not found", context = TAG) + return null + } + + val secretKey = keystore.getKey(alias, null) as javax.crypto.SecretKey + + val transformation = "AES/GCM/NoPadding" + val spec = javax.crypto.spec.GCMParameterSpec(GCM_TAG_LENGTH, iv) + val cipher = javax.crypto.Cipher.getInstance(transformation).apply { + init(javax.crypto.Cipher.DECRYPT_MODE, secretKey, spec) + } + + val decryptedBytes = cipher.doFinal(ciphertext) + String(decryptedBytes, Charsets.UTF_8) + } catch (e: Exception) { + Logger.error("Failed to decrypt with Keystore: $e", e, context = TAG) + null + } + } + + @Suppress("TooGenericExceptionCaught") + suspend fun migrateFromReactNative() { + setShowingMigrationLoading(true) + + try { + val mnemonicMigrated = try { + migrateMnemonic() + true + } catch (e: Exception) { + Logger.warn( + "Could not migrate mnemonic: $e. User will need to manually restore.", + context = TAG + ) + false + } + + if (mnemonicMigrated) { + migratePassphrase() + migratePin() + + if (hasRNLdkData()) { + migrateLdkData() + .onFailure { e -> + Logger.warn( + "LDK data migration failed, continuing with other migrations: $e", + e, + context = TAG + ) + } + } + + if (hasRNMmkvData()) { + migrateMMKVData() + } + + rnMigrationStore.edit { + it[stringPreferencesKey(RN_MIGRATION_COMPLETED_KEY)] = "true" + it[stringPreferencesKey(RN_MIGRATION_CHECKED_KEY)] = "true" + } + } else { + markMigrationChecked() + setShowingMigrationLoading(false) + throw to.bitkit.utils.AppError( + "Migration data unavailable. Please restore your wallet using your recovery phrase." + ) + } + } catch (e: Exception) { + Logger.error("RN migration failed: $e", e, context = TAG) + markMigrationChecked() + setShowingMigrationLoading(false) + throw e + } + } + + private suspend fun migrateMnemonic() { + val mnemonic = loadStringFromRNKeychain(RNKeychainKey.MNEMONIC) + + if (mnemonic.isNullOrEmpty()) { + throw to.bitkit.utils.AppError( + "Migration data unavailable. Please restore your wallet using your recovery phrase." + ) + } + + bip39Service.validateMnemonic(mnemonic).onFailure { + throw to.bitkit.utils.AppError( + "Recovery phrase is invalid. Please use your 12 or 24 word recovery phrase to restore manually." + ) } - val path = file.path - Logger.debug("Creating ldk-node db at: $path") - Logger.debug("Seeding ldk-node db with LDK backup data…") + keychain.saveString(Keychain.Key.BIP39_MNEMONIC.name, mnemonic) + } - LdkNodeDataDbHelper(context, path).writableDatabase.use { - it.beginTransaction() + private suspend fun migratePassphrase() { + val passphrase = loadStringFromRNKeychain(RNKeychainKey.PASSPHRASE) + if (passphrase.isNullOrEmpty()) { + return + } + keychain.saveString(Keychain.Key.BIP39_PASSPHRASE.name, passphrase) + } + + private suspend fun migratePin() { + val pin = loadStringFromRNKeychain(RNKeychainKey.PIN) + if (pin.isNullOrEmpty()) { + return + } + + if (pin.length != Env.PIN_LENGTH) { + Logger.warn( + "Invalid PIN length during migration: ${pin.length}, expected: ${Env.PIN_LENGTH}", + context = TAG, + ) + return + } + + if (!pin.all { it.isDigit() }) { + Logger.warn("Invalid PIN format during migration: contains non-numeric characters", context = TAG) + return + } + + keychain.saveString(Keychain.Key.PIN.name, pin) + } + + private suspend fun migrateLdkData() = runCatching { + val accountPath = rnLdkAccountPath + val managerPath = File(accountPath, "channel_manager.bin") + + if (!managerPath.exists()) { + Logger.warn("LDK channel_manager.bin not found at ${managerPath.path}", context = TAG) + return@runCatching + } + + val managerData = managerPath.readBytes() + val monitors = mutableListOf() + + val channelsPath = File(accountPath, "channels") + val monitorsPath = File(accountPath, "monitors") + val monitorDir = if (channelsPath.exists()) channelsPath else monitorsPath + + monitorDir.takeIf { it.exists() }?.listFiles()?.forEach { file -> + if (file.name.endsWith(".bin")) { + monitors.add(file.readBytes()) + } + } + + pendingChannelMigration = PendingChannelMigration( + channelManager = managerData, + channelMonitors = monitors, + ) + }.onFailure { e -> + Logger.error("Failed to migrate LDK data: $e", e, context = TAG) + } + + suspend fun loadRNMmkvData(): Map? = runCatching { + if (!hasRNMmkvData()) { + return@runCatching null + } + + val data = rnMmkvPath.readBytes() + val parser = MmkvParser(data) + parser.parse().takeIf { it.isNotEmpty() } + }.onFailure { e -> + Logger.error("Failed to read MMKV data: $e", e, context = TAG) + }.getOrNull() + + @Suppress("TooGenericExceptionCaught") + private suspend fun extractRNSettings(mmkvData: Map): RNSettings? { + val rootJson = mmkvData["persist:root"] ?: return null + + return try { + val jsonStart = rootJson.indexOf("{") + val jsonString = if (jsonStart >= 0) rootJson.substring(jsonStart) else rootJson + + val root = json.parseToJsonElement(jsonString).jsonObject + val settingsJsonString = root["settings"]?.jsonPrimitive?.content ?: return null + + json.decodeFromString(settingsJsonString) + } catch (e: Exception) { + Logger.error("Failed to decode RN settings: $e", e, context = TAG) + null + } + } + + @Suppress("TooGenericExceptionCaught") + private suspend fun extractRNMetadata(mmkvData: Map): RNMetadata? { + val rootJson = mmkvData["persist:root"] ?: return null + + return try { + val jsonStart = rootJson.indexOf("{") + val jsonString = if (jsonStart >= 0) rootJson.substring(jsonStart) else rootJson + + val root = json.parseToJsonElement(jsonString).jsonObject + val metadataJsonString = root["metadata"]?.jsonPrimitive?.content ?: return null + + json.decodeFromString(metadataJsonString) + } catch (e: Exception) { + Logger.error("Failed to decode RN metadata: $e", e, context = TAG) + null + } + } + + @Suppress("TooGenericExceptionCaught") + private suspend fun extractRNWidgets(mmkvData: Map): RNWidgetsWithOptions? { + val rootJson = mmkvData["persist:root"] ?: return null + + return try { + val jsonStart = rootJson.indexOf("{") + val jsonString = if (jsonStart >= 0) rootJson.substring(jsonStart) else rootJson + + val root = json.parseToJsonElement(jsonString).jsonObject + val widgetsJsonString = root["widgets"]?.jsonPrimitive?.content ?: return null + + val widgets = json.decodeFromString(widgetsJsonString) + + val widgetsData = json.parseToJsonElement(widgetsJsonString).jsonObject + val widgetOptions = convertRNWidgetPreferences(widgetsData["widgets"]?.jsonObject ?: widgetsData) + + RNWidgetsWithOptions(widgets = widgets, widgetOptions = widgetOptions) + } catch (e: Exception) { + Logger.error("Failed to decode RN widgets: $e", e, context = TAG) + null + } + } + + @Suppress("TooGenericExceptionCaught") + private suspend fun extractRNActivities(mmkvData: Map): List? { + val rootJson = mmkvData["persist:root"] ?: return null + + return try { + val jsonStart = rootJson.indexOf("{") + val jsonString = if (jsonStart >= 0) rootJson.substring(jsonStart) else rootJson + + val root = json.parseToJsonElement(jsonString).jsonObject + val activityJsonString = root["activity"]?.jsonPrimitive?.content ?: return null + + val activityState = json.decodeFromString(activityJsonString) + activityState.items ?: emptyList() + } catch (e: Exception) { + Logger.error("Failed to decode RN activities: $e", e, context = TAG) + null + } + } + + private fun extractTransfers(transfers: Map>?): Map { + val transferMap = mutableMapOf() + transfers?.values?.flatten()?.forEach { transfer -> + transfer.txId?.let { txId -> + transfer.type?.let { type -> + transferMap[txId] = type + } + } + } + return transferMap + } + + private fun extractBoosts(boostedTxs: Map>?): Map { + val boostMap = mutableMapOf() + boostedTxs?.values?.forEach { networkBoosts -> + networkBoosts.forEach { (parentTxId, boost) -> + val childTxId = boost.childTransaction ?: boost.newTxId + childTxId?.let { + boostMap[parentTxId] = it + } + } + } + return boostMap + } + + private fun extractFromWalletState(walletState: RNWalletState): Pair, Map>? { + val transferMap = mutableMapOf() + val boostMap = mutableMapOf() + + walletState.wallets?.values?.forEach { walletDataItem -> + walletDataItem.transfers?.let { transferMap.putAll(extractTransfers(it)) } + walletDataItem.boostedTransactions?.let { boostMap.putAll(extractBoosts(it)) } + } + + return if (transferMap.isNotEmpty() || boostMap.isNotEmpty()) { + Pair(transferMap, boostMap) + } else { + null + } + } + + private fun extractFromWalletBackup( + walletBackup: RNRemoteWalletBackup, + ): Pair, Map>? { + val transferMap = extractTransfers(walletBackup.transfers) + val boostMap = extractBoosts(walletBackup.boostedTransactions) + + return if (transferMap.isNotEmpty() || boostMap.isNotEmpty()) { + Pair(transferMap, boostMap) + } else { + null + } + } + + @Suppress("TooGenericExceptionCaught") + private suspend fun extractRNWalletBackup( + mmkvData: Map + ): Pair, Map>? { + val rootJson = mmkvData["persist:root"] ?: return null + + return try { + val jsonStart = rootJson.indexOf("{") + val jsonString = if (jsonStart >= 0) rootJson.substring(jsonStart) else rootJson + + val root = json.parseToJsonElement(jsonString).jsonObject + val walletJsonString = root["wallet"]?.jsonPrimitive?.content ?: return null + val walletData = json.parseToJsonElement(walletJsonString).jsonObject + + val walletState = runCatching { + json.decodeFromJsonElement(walletData) + }.getOrNull() + + walletState?.let { extractFromWalletState(it) } ?: run { + val walletBackup = runCatching { + json.decodeFromJsonElement(walletData) + }.getOrNull() + walletBackup?.let { extractFromWalletBackup(it) } + } + } catch (e: Exception) { + Logger.error("Failed to decode RN wallet backup: $e", e, context = TAG) + null + } + } + + @Serializable + private data class RNWalletState(val wallets: Map? = null) + + @Serializable + private data class RNWalletData( + val transfers: Map>? = null, + val boostedTransactions: Map>? = null, + ) + + @Suppress("NestedBlockDepth", "TooGenericExceptionCaught") + private suspend fun extractRNClosedChannels(mmkvData: Map): List? { + val rootJson = mmkvData["persist:root"] ?: return null + + return try { + val jsonStart = rootJson.indexOf("{") + val jsonString = if (jsonStart >= 0) rootJson.substring(jsonStart) else rootJson + + val root = json.parseToJsonElement(jsonString).jsonObject + val lightningJsonString = root["lightning"]?.jsonPrimitive?.content ?: return null + + val lightningState = json.decodeFromString(lightningJsonString) + val closedChannels = mutableListOf() + + lightningState.nodes?.forEach { (_, node) -> + node.channels?.forEach { (_, channels) -> + channels.forEach { (_, channel) -> + if (channel.status == "closed") { + closedChannels.add(channel) + } + } + } + } + + closedChannels.takeIf { it.isNotEmpty() } + } catch (e: Exception) { + Logger.error("Failed to decode RN lightning state: $e", e, context = TAG) + null + } + } + + @Suppress("CyclomaticComplexMethod") + private suspend fun applyRNSettings(settings: RNSettings) { + settingsStore.update { current -> + current.copy( + selectedCurrency = settings.selectedCurrency ?: current.selectedCurrency, + primaryDisplay = when (settings.unit) { + "BTC" -> PrimaryDisplay.BITCOIN + else -> PrimaryDisplay.FIAT + }, + displayUnit = when (settings.denomination) { + "sats" -> BitcoinDisplayUnit.MODERN + "BTC" -> BitcoinDisplayUnit.CLASSIC + else -> current.displayUnit + }, + hideBalance = settings.hideBalance ?: current.hideBalance, + hideBalanceOnOpen = settings.hideBalanceOnOpen ?: current.hideBalanceOnOpen, + enableSwipeToHideBalance = settings.enableSwipeToHideBalance ?: current.enableSwipeToHideBalance, + isQuickPayEnabled = settings.enableQuickpay ?: current.isQuickPayEnabled, + quickPayAmount = settings.quickpayAmount ?: current.quickPayAmount, + enableAutoReadClipboard = settings.enableAutoReadClipboard ?: current.enableAutoReadClipboard, + enableSendAmountWarning = settings.enableSendAmountWarning ?: current.enableSendAmountWarning, + showWidgets = settings.showWidgets ?: current.showWidgets, + showWidgetTitles = settings.showWidgetTitles ?: current.showWidgetTitles, + defaultTransactionSpeed = when (settings.transactionSpeed) { + "fast" -> TransactionSpeed.Fast + "slow" -> TransactionSpeed.Slow + else -> TransactionSpeed.Medium + }, + coinSelectAuto = settings.coinSelectAuto ?: current.coinSelectAuto, + coinSelectPreference = when (settings.coinSelectPreference) { + "branchAndBound" -> CoinSelectionPreference.BranchAndBound + else -> CoinSelectionPreference.SmallestFirst + }, + isPinEnabled = settings.pin ?: current.isPinEnabled, + isPinOnLaunchEnabled = settings.pinOnLaunch ?: current.isPinOnLaunchEnabled, + isPinOnIdleEnabled = settings.pinOnIdle ?: current.isPinOnIdleEnabled, + isPinForPaymentsEnabled = settings.pinForPayments ?: current.isPinForPaymentsEnabled, + isBiometricEnabled = settings.biometrics ?: current.isBiometricEnabled, + quickPayIntroSeen = settings.quickpayIntroSeen ?: current.quickPayIntroSeen, + hasSeenShopIntro = settings.shopIntroSeen ?: current.hasSeenShopIntro, + hasSeenTransferIntro = settings.transferIntroSeen ?: current.hasSeenTransferIntro, + hasSeenSpendingIntro = settings.spendingIntroSeen ?: current.hasSeenSpendingIntro, + hasSeenSavingsIntro = settings.savingsIntroSeen ?: current.hasSeenSavingsIntro, + ) + } + } + + private suspend fun applyRNMetadata(metadata: RNMetadata) { + val allTags = metadata.tags?.mapNotNull { (txId, tagList) -> + runCatching { + var activityId = txId + activityRepo.getOnchainActivityByTxId(txId)?.let { + activityId = it.id + } + ActivityTags(activityId = activityId, tags = tagList) + }.onFailure { e -> + Logger.error("Failed to get activity ID for $txId: $e", e, context = TAG) + }.getOrNull() + } ?: emptyList() + + if (allTags.isNotEmpty()) { + runCatching { + coreService.activity.upsertTags(allTags) + }.onFailure { e -> + Logger.error("Failed to migrate tags: $e", e, context = TAG) + } + } + + metadata.lastUsedTags?.forEach { + settingsStore.addLastUsedTag(it) + } + } + + private suspend fun applyRNActivities(items: List) { + val activities = items.filter { it.activityType == "lightning" }.map { item -> + val txType = if (item.txType == "sent") PaymentType.SENT else PaymentType.RECEIVED + val status = when (item.status) { + "successful", "succeeded" -> PaymentState.SUCCEEDED + "failed" -> PaymentState.FAILED + else -> PaymentState.PENDING + } + + val timestampSecs = (item.timestamp / MILLISECONDS_TO_SECONDS).toULong() + val invoice = item.address?.takeIf { it.isNotEmpty() } ?: "migrated:${item.id}" + + Activity.Lightning( + LightningActivity( + id = item.id, + txType = txType, + status = status, + value = item.value.toULong(), + fee = item.fee?.toULong(), + invoice = invoice, + message = item.message ?: "", + timestamp = timestampSecs, + preimage = item.preimage, + createdAt = timestampSecs, + updatedAt = timestampSecs, + seenAt = timestampSecs, + ) + ) + } + + activities.forEach { activity -> + activityRepo.upsertActivity(activity) + } + } + + private suspend fun applyRNClosedChannels(channels: List) { + val now = (System.currentTimeMillis() / MILLISECONDS_TO_SECONDS).toULong() + + val closedChannels = channels.mapNotNull { channel -> + val fundingTxid = channel.fundingTxid ?: return@mapNotNull null + + val closedAtSecs = channel.createdAt?.let { + (it / MILLISECONDS_TO_SECONDS).toULong() + } ?: now + + val outboundMsat = (channel.outboundCapacitySat ?: 0u) * 1000u + val inboundMsat = (channel.inboundCapacitySat ?: 0u) * 1000u + + ClosedChannelDetails( + channelId = channel.channelId, + counterpartyNodeId = channel.counterpartyNodeId ?: "", + fundingTxoTxid = fundingTxid, + fundingTxoIndex = 0u, + channelValueSats = channel.channelValueSatoshis ?: 0u, + closedAt = closedAtSecs, + outboundCapacityMsat = outboundMsat, + inboundCapacityMsat = inboundMsat, + counterpartyUnspendablePunishmentReserve = channel.counterpartyUnspendablePunishmentReserve ?: 0u, + unspendablePunishmentReserve = channel.unspendablePunishmentReserve ?: 0u, + forwardingFeeProportionalMillionths = 0u, + forwardingFeeBaseMsat = 0u, + channelName = "", + channelClosureReason = channel.closureReason ?: "unknown", + ) + } + + if (closedChannels.isNotEmpty()) { + runCatching { + coreService.activity.upsertClosedChannelList(closedChannels) + }.onFailure { e -> + Logger.error("Failed to migrate closed channels: $e", e, context = TAG) + } + } + } + + private suspend fun applyRNWidgets(widgetsWithOptions: RNWidgetsWithOptions) { + val widgets = widgetsWithOptions.widgets + val widgetOptions = widgetsWithOptions.widgetOptions + + widgets.sortOrder?.let { sortOrder -> + val widgetTypeMap = mapOf( + "price" to WidgetType.PRICE, + "news" to WidgetType.NEWS, + "blocks" to WidgetType.BLOCK, + "weather" to WidgetType.WEATHER, + "facts" to WidgetType.FACTS, + ) + + val savedWidgets = sortOrder.mapNotNull { widgetName -> + widgetTypeMap[widgetName]?.let { type -> + WidgetWithPosition(type = type, position = sortOrder.indexOf(widgetName)) + } + } + + if (savedWidgets.isNotEmpty()) { + widgetsStore.updateWidgets(savedWidgets) + } + } + + applyRNWidgetPreferences(widgetOptions) + + widgets.onboardedWidgets?.takeIf { it }?.let { + settingsStore.update { it.copy(hasSeenWidgetsIntro = true) } + } + } + + @Suppress("LongMethod", "CyclomaticComplexMethod", "TooGenericExceptionCaught") + private suspend fun applyRNWidgetPreferences(widgetOptions: Map) { + widgetOptions["price"]?.let { priceData -> + try { + val priceJson = json.decodeFromString( + priceData.decodeToString() + ) + val selectedPairs = priceJson["selectedPairs"]?.jsonArray?.mapNotNull { pairElement -> + val pairStr = pairElement.jsonPrimitive.content.replace("_", "/") + when (pairStr) { + "BTC/USD" -> to.bitkit.data.dto.price.TradingPair.BTC_USD + "BTC/EUR" -> to.bitkit.data.dto.price.TradingPair.BTC_EUR + "BTC/GBP" -> to.bitkit.data.dto.price.TradingPair.BTC_GBP + "BTC/JPY" -> to.bitkit.data.dto.price.TradingPair.BTC_JPY + else -> null + } + } ?: listOf(to.bitkit.data.dto.price.TradingPair.BTC_USD) + + val periodStr = priceJson["selectedPeriod"]?.jsonPrimitive?.content ?: "1D" + val period = when (periodStr) { + "1D" -> to.bitkit.data.dto.price.GraphPeriod.ONE_DAY + "1W" -> to.bitkit.data.dto.price.GraphPeriod.ONE_WEEK + "1M" -> to.bitkit.data.dto.price.GraphPeriod.ONE_MONTH + "1Y" -> to.bitkit.data.dto.price.GraphPeriod.ONE_YEAR + else -> to.bitkit.data.dto.price.GraphPeriod.ONE_DAY + } + + val showSource = priceJson["showSource"]?.jsonPrimitive?.content + ?.toBooleanStrictOrNull() ?: false + + widgetsStore.updatePricePreferences( + to.bitkit.models.widget.PricePreferences( + enabledPairs = selectedPairs, + period = period, + showSource = showSource + ) + ) + } catch (e: Exception) { + Logger.error("Failed to migrate price preferences: $e", e, context = TAG) + } + } + + widgetOptions["weather"]?.let { weatherData -> + try { + val weatherJson = json.decodeFromString( + weatherData.decodeToString() + ) + val showTitle = weatherJson["showStatus"]?.jsonPrimitive?.content?.toBooleanStrictOrNull() ?: true + val showDescription = weatherJson["showText"]?.jsonPrimitive?.content?.toBooleanStrictOrNull() ?: false + val showCurrentFee = weatherJson["showMedian"]?.jsonPrimitive?.content?.toBooleanStrictOrNull() ?: false + val showNextBlockFee = weatherJson["showNextBlockFee"]?.jsonPrimitive?.content + ?.toBooleanStrictOrNull() ?: false + + widgetsStore.updateWeatherPreferences( + to.bitkit.models.widget.WeatherPreferences( + showTitle = showTitle, + showDescription = showDescription, + showCurrentFee = showCurrentFee, + showNextBlockFee = showNextBlockFee + ) + ) + } catch (e: Exception) { + Logger.error("Failed to migrate weather preferences: $e", e, context = TAG) + } + } + + widgetOptions["news"]?.let { newsData -> try { - it.insertManager(manager) - it.insertMonitors(seed, monitors) + val newsJson = json.decodeFromString( + newsData.decodeToString() + ) + val showTime = newsJson["showDate"]?.jsonPrimitive?.content + ?.toBooleanStrictOrNull() ?: true + val showSource = newsJson["showSource"]?.jsonPrimitive?.content + ?.toBooleanStrictOrNull() ?: true - it.execSQL("DROP TABLE IF EXISTS android_metadata") - it.setTransactionSuccessful() - } finally { - it.endTransaction() + widgetsStore.updateHeadlinePreferences( + to.bitkit.models.widget.HeadlinePreferences( + showTime = showTime, + showSource = showSource + ) + ) + } catch (e: Exception) { + Logger.error("Failed to migrate news preferences: $e", e, context = TAG) } } - File("$path-journal").delete() + widgetOptions["blocks"]?.let { blocksData -> + try { + val blocksJson = json.decodeFromString( + blocksData.decodeToString() + ) + val showBlock = blocksJson["height"]?.jsonPrimitive?.content?.toBooleanStrictOrNull() ?: true + val showTime = blocksJson["time"]?.jsonPrimitive?.content?.toBooleanStrictOrNull() ?: true + val showDate = blocksJson["date"]?.jsonPrimitive?.content?.toBooleanStrictOrNull() ?: true + val showTransactions = blocksJson["transactionCount"]?.jsonPrimitive?.content + ?.toBooleanStrictOrNull() ?: false + val showSize = blocksJson["size"]?.jsonPrimitive?.content?.toBooleanStrictOrNull() ?: false + val showSource = blocksJson["showSource"]?.jsonPrimitive?.content?.toBooleanStrictOrNull() ?: false + + widgetsStore.updateBlocksPreferences( + to.bitkit.models.widget.BlocksPreferences( + showBlock = showBlock, + showTime = showTime, + showDate = showDate, + showTransactions = showTransactions, + showSize = showSize, + showSource = showSource + ) + ) + } catch (e: Exception) { + Logger.error("Failed to migrate blocks preferences: $e", e, context = TAG) + } + } + + widgetOptions["facts"]?.let { factsData -> + try { + val factsJson = json.decodeFromString( + factsData.decodeToString() + ) + val showSource = factsJson["showSource"]?.jsonPrimitive?.content?.toBooleanStrictOrNull() ?: false - Logger.info("Migrated LDK backup to ldk-node db at: $path") + widgetsStore.updateFactsPreferences( + to.bitkit.models.widget.FactsPreferences( + showSource = showSource + ) + ) + } catch (e: Exception) { + Logger.error("Failed to migrate facts preferences: $e", e, context = TAG) + } + } + } + + private suspend fun migrateMMKVData() { + val mmkvData = loadRNMmkvData() ?: return + + extractRNActivities(mmkvData)?.let { activities -> + applyRNActivities(activities) + } + + extractRNClosedChannels(mmkvData)?.let { channels -> + applyRNClosedChannels(channels) + } + + extractRNSettings(mmkvData)?.let { settings -> + applyRNSettings(settings) + } + + extractRNMetadata(mmkvData)?.let { metadata -> + applyRNMetadata(metadata) + } + + extractRNWidgets(mmkvData)?.let { widgets -> + applyRNWidgets(widgets) + } } - private fun SQLiteDatabase.insertManager(manager: ByteArray) { - val values = ContentValues().apply { - put(PRIMARY_NAMESPACE, "") - put(SECONDARY_NAMESPACE, "") - put(KEY, "manager") - put(VALUE, manager) + suspend fun hasRNRemoteBackup(): Boolean = runCatching { + rnBackupClient.hasBackup() + }.onFailure { e -> + Logger.error("Failed to check RN remote backup", e, context = TAG) + }.getOrDefault(false) + + suspend fun getRNRemoteBackupTimestamp(): ULong? = runCatching { + rnBackupClient.getLatestBackupTimestamp() + }.getOrNull() + + suspend fun restoreFromRNRemoteBackup() { + setRestoringFromRNRemoteBackup(true) + + try { + fetchRNRemoteLdkData() + val bitkitFiles = rnBackupClient.listFiles(fileGroup = "bitkit")?.list ?: emptyList() + retrieveAndApplyBitkitBackups(bitkitFiles) + markMigrationCompleted() + } catch (e: Exception) { + Logger.error("RN remote backup restore failed", e, context = TAG) + throw e } - insert(LDK_NODE_DATA, null, values) } - private fun SQLiteDatabase.insertMonitors(seed: ByteArray, monitors: List) { - val seconds = System.currentTimeMillis() / 1000L - val nanoSeconds = (seconds * 1000 * 1000).toInt() + private suspend fun retrieveAndApplyBitkitBackups(availableFiles: List) = coroutineScope { + fun fileExists(name: String) = availableFiles.any { it.removeSuffix(".bin") == name } + + suspend fun retrieve(name: String): ByteArray? { + if (!fileExists(name)) return null + return rnBackupClient.retrieve(name, fileGroup = "bitkit") + } + + val settingsData = async { retrieve("bitkit_settings") } + val widgetsData = async { retrieve("bitkit_widgets") } + val activityData = async { retrieve("bitkit_lightning_activity") } + val metadataData = async { retrieve("bitkit_metadata") } + val walletData = async { retrieve("bitkit_wallet") } + val blocktankData = async { retrieve("bitkit_blocktank_orders") } + + settingsData.await()?.let { applyRNRemoteSettings(it) } + widgetsData.await()?.let { applyRNRemoteWidgets(it) } + activityData.await()?.let { applyRNRemoteActivity(it) } + metadataData.await()?.let { applyRNRemoteMetadata(it) } + walletData.await()?.let { applyRNRemoteWallet(it) } + blocktankData.await()?.let { applyRNRemoteBlocktank(it) } + } - val keysManager = KeysManager.of(seed, seconds, nanoSeconds) - val (entropySource, signerProvider) = keysManager.as_EntropySource() to keysManager.as_SignerProvider() + private suspend fun markMigrationCompleted() { + rnMigrationStore.edit { + it[stringPreferencesKey(RN_MIGRATION_COMPLETED_KEY)] = "true" + it[stringPreferencesKey(RN_MIGRATION_CHECKED_KEY)] = "true" + } + } - for (monitor in monitors) { - val channelMonitor = read32BytesChannelMonitor(monitor, entropySource, signerProvider).takeIf { it.is_ok } - ?.let { it as? ChannelMonitorDecodeResultTuple }?.res?._b - ?: throw ServiceError.LdkToLdkNodeMigration - val fundingTx = channelMonitor._funding_txo._a._txid?.reversedArray()?.toHex() - ?: throw ServiceError.LdkToLdkNodeMigration - val index = channelMonitor._funding_txo._a._index - val key = "${fundingTx}_$index" + private suspend fun fetchRNRemoteLdkData() { + runCatching { + val files = rnBackupClient.listFiles(fileGroup = "ldk") ?: return@runCatching + if (!files.list.any { it.removeSuffix(".bin") == "channel_manager" }) return@runCatching - val values = ContentValues().apply { - put(PRIMARY_NAMESPACE, "monitors") - put(SECONDARY_NAMESPACE, "") - put(KEY, key) - put(VALUE, monitor) + val managerData = rnBackupClient.retrieve("channel_manager", fileGroup = "ldk") + ?: return@runCatching + + val monitors = coroutineScope { + files.channelMonitors.map { monitorFile -> + async { + val channelId = monitorFile.replace(".bin", "") + rnBackupClient.retrieveChannelMonitor(channelId) + } + }.mapNotNull { it.await() } } - insert(LDK_NODE_DATA, null, values) - Logger.debug("Inserted monitor: $key") + if (monitors.isNotEmpty()) { + pendingChannelMigration = PendingChannelMigration( + channelManager = managerData, + channelMonitors = monitors, + ) + } + }.onFailure { e -> + Logger.error("Failed to fetch remote LDK data", e, context = TAG) } } - class LdkNodeDataDbHelper(context: Context, name: String) : SQLiteOpenHelper(context, name, null, LDK_DB_VERSION) { - override fun onCreate(db: SQLiteDatabase) { - val query = """ - |CREATE TABLE ldk_node_data ( - | primary_namespace TEXT NOT NULL, - | secondary_namespace TEXT DEFAULT "" NOT NULL, - | `key` TEXT NOT NULL CHECK (`key` <> ''), - | value BLOB, - | PRIMARY KEY (primary_namespace, secondary_namespace, `key`) - |); - """.trimMargin() - db.execSQL(query) + private suspend fun applyRNRemoteSettings(data: ByteArray) { + runCatching { + applyRNSettings(decodeBackupData(data)) + settingsStore.update { it.resetPin() } + }.onFailure { e -> + Logger.warn("Failed to decode RN remote settings backup: $e", context = TAG) } + } - override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit + private suspend fun applyRNRemoteWidgets(data: ByteArray) { + runCatching { + val widgets = decodeBackupData(data) + val rawJson = runCatching { json.parseToJsonElement(String(data)) }.getOrNull() + val widgetOptions = rawJson?.jsonObject?.get("data")?.jsonObject?.let { dataObj -> + convertRNWidgetPreferences(dataObj["widgets"]?.jsonObject ?: dataObj) + } ?: emptyMap() + + applyRNWidgets(RNWidgetsWithOptions(widgets = widgets, widgetOptions = widgetOptions)) + }.onFailure { e -> + Logger.warn("Failed to decode RN remote widgets backup: $e", context = TAG) + } } - companion object { - private const val LDK_NODE_DATA = "ldk_node_data" - private const val PRIMARY_NAMESPACE = "primary_namespace" - private const val SECONDARY_NAMESPACE = "secondary_namespace" - private const val KEY = "key" - private const val VALUE = "value" - private const val LDK_DB_NAME = "$LDK_NODE_DATA.sqlite" - private const val LDK_DB_VERSION = 2 // Should match SCHEMA_USER_VERSION from ldk-node + private suspend fun applyRNRemoteActivity(data: ByteArray) { + runCatching { + val items = decodeBackupData>(data).map { item -> + RNActivityItem( + id = item.id, + activityType = item.activityType, + txType = item.txType, + txId = item.txId, + value = item.value, + fee = item.fee, + feeRate = item.feeRate, + address = item.address, + confirmed = item.confirmed, + timestamp = item.timestamp, + isBoosted = item.isBoosted, + isTransfer = item.isTransfer, + exists = item.exists, + confirmTimestamp = item.confirmTimestamp, + channelId = item.channelId, + transferTxId = item.transferTxId, + status = item.status, + message = item.message, + preimage = item.preimage, + boostedParents = item.boostedParents, + ) + } + + pendingRemoteActivityData = items + applyRNActivities(items) + }.onFailure { e -> + Logger.warn("Failed to decode RN remote activity backup: $e", context = TAG) + } + } + + private suspend fun applyRNRemoteMetadata(data: ByteArray) { + runCatching { + pendingRemoteMetadata = decodeBackupData(data) + }.onFailure { e -> + Logger.warn("Failed to decode RN remote metadata backup: $e", context = TAG) + } + } + + private suspend fun applyRNRemoteWallet(data: ByteArray) { + runCatching { + val backup = decodeBackupData(data) + + backup.transfers?.let { transfers -> + val transferMap = mutableMapOf() + transfers.values.flatten().forEach { transfer -> + transfer.txId?.let { txId -> + transfer.type?.let { type -> + transferMap[txId] = type + } + } + } + if (transferMap.isNotEmpty()) { + pendingRemoteTransfers = transferMap + } + } + + backup.boostedTransactions?.let { boostedTxs -> + val boostMap = mutableMapOf() + boostedTxs.values.forEach { networkBoosts -> + networkBoosts.forEach { (oldTxId, boost) -> + val childTxId = boost.childTransaction ?: boost.newTxId + childTxId?.let { + boostMap[oldTxId] = it + } + } + } + if (boostMap.isNotEmpty()) { + Logger.info("Found ${boostMap.size} boosted transactions in remote backup", context = TAG) + pendingRemoteBoosts = boostMap + } else { + Logger.debug("No boosted transactions found in RN remote wallet backup", context = TAG) + } + } + }.onFailure { e -> + Logger.warn("Failed to decode RN remote wallet backup: $e", context = TAG) + } + } + + private suspend fun applyRNRemoteBlocktank(data: ByteArray) { + runCatching { + val backup = decodeBackupData(data) + val orderIds = mutableListOf() + + backup.orders?.let { orders -> + orderIds.addAll(orders.map { it.id }) + } + + backup.paidOrders?.let { paidOrderIds -> + orderIds.addAll(paidOrderIds) + } + + if (orderIds.isNotEmpty()) { + runCatching { + val fetchedOrders = coreService.blocktank.orders( + orderIds = orderIds, + filter = null, + refresh = true, + ) + if (fetchedOrders.isNotEmpty()) { + coreService.blocktank.upsertOrderList(fetchedOrders) + } + }.onFailure { e -> + Logger.warn("Failed to fetch and upsert Blocktank orders: $e", context = TAG) + } + } + }.onFailure { e -> + Logger.warn("Failed to decode RN remote blocktank backup: $e", context = TAG) + } + } + + suspend fun reapplyMetadataAfterSync() { + if (hasRNMmkvData()) { + val mmkvData = loadRNMmkvData() ?: return + + extractRNMetadata(mmkvData)?.let { metadata -> + applyRNMetadata(metadata) + } + + extractRNActivities(mmkvData)?.let { activities -> + applyOnchainMetadata(activities) + } + + extractRNWalletBackup(mmkvData)?.let { (transfers, boosts) -> + if (transfers.isNotEmpty()) { + Logger.info("Applying ${transfers.size} local transfer markers", context = TAG) + applyRemoteTransfers(transfers) + } + if (boosts.isNotEmpty()) { + Logger.info("Applying ${boosts.size} local boost markers", context = TAG) + applyBoostTransactions(boosts) + } + } + } + + pendingRemoteActivityData?.let { remoteActivities -> + applyOnchainMetadata(remoteActivities) + pendingRemoteActivityData = null + } + + pendingRemoteTransfers?.let { transfers -> + applyRemoteTransfers(transfers) + pendingRemoteTransfers = null + } + + pendingRemoteBoosts?.let { boosts -> + applyBoostTransactions(boosts) + pendingRemoteBoosts = null + } + + pendingRemoteMetadata?.let { metadata -> + applyRNMetadata(metadata) + pendingRemoteMetadata = null + } + } + + private suspend fun applyRemoteTransfers(transfers: Map) { + transfers.forEach { (txId, channelId) -> + val onchain = activityRepo.getOnchainActivityByTxId(txId) ?: return@forEach + val updated = onchain.copy(isTransfer = true, channelId = channelId) + activityRepo.updateActivity(onchain.id, Activity.Onchain(updated)) + } + } + + private suspend fun applyBoostTransactions(boosts: Map) { + var applied = 0 + + boosts.forEach { (oldTxId, newTxId) -> + val oldOnchain = activityRepo.getOnchainActivityByTxId(oldTxId) + val newOnchain = activityRepo.getOnchainActivityByTxId(newTxId) + + if (oldOnchain != null && newOnchain != null) { + var parentOnchain = oldOnchain + val updatedParentBoostTxIds = if (newTxId !in parentOnchain.boostTxIds) { + parentOnchain.boostTxIds + newTxId + } else { + parentOnchain.boostTxIds + } + parentOnchain = parentOnchain.copy( + isBoosted = true, + boostTxIds = updatedParentBoostTxIds, + ) + + var updatedNewOnchain = newOnchain.copy( + isBoosted = false, + boostTxIds = newOnchain.boostTxIds.filter { it != oldTxId }, + ) + + runCatching { + activityRepo.updateActivity(parentOnchain.id, Activity.Onchain(parentOnchain)) + activityRepo.updateActivity(updatedNewOnchain.id, Activity.Onchain(updatedNewOnchain)) + applied++ + }.onFailure { e -> + Logger.error( + "Failed to apply CPFP boost for parent $oldTxId / child $newTxId: $e", + e, + context = TAG + ) + } + } else if (newOnchain != null) { + val updatedBoostTxIds = if (oldTxId !in newOnchain.boostTxIds) { + newOnchain.boostTxIds + oldTxId + } else { + newOnchain.boostTxIds + } + val updated = newOnchain.copy( + isBoosted = true, + boostTxIds = updatedBoostTxIds, + ) + + runCatching { + activityRepo.updateActivity(updated.id, Activity.Onchain(updated)) + applied++ + }.onFailure { e -> + Logger.error("Failed to apply RBF boost for tx $newTxId: $e", e, context = TAG) + } + } + } + + Logger.info("Applied $applied/${boosts.size} boost markers", context = TAG) + } + + private suspend fun applyBoostedParents(boostedParents: List, txId: String) { + boostedParents.forEach { parentTxId -> + val parentOnchain = activityRepo.getOnchainActivityByTxId(parentTxId) + if (parentOnchain != null) { + val updatedParentBoostTxIds = if (txId !in parentOnchain.boostTxIds) { + parentOnchain.boostTxIds + txId + } else { + parentOnchain.boostTxIds + } + val updatedParent = parentOnchain.copy( + isBoosted = true, + boostTxIds = updatedParentBoostTxIds, + ) + + runCatching { + activityRepo.updateActivity(updatedParent.id, Activity.Onchain(updatedParent)) + }.onFailure { e -> + Logger.error("Failed to mark parent $parentTxId as boosted for CPFP: $e", e, context = TAG) + } + } + } } + + private suspend fun updateOnchainActivityMetadata( + item: RNActivityItem, + onchain: OnchainActivity, + ): OnchainActivity? { + var updated: OnchainActivity = onchain + var wasUpdated = false + + if (item.timestamp > 0) { + val migratedTimestamp = (item.timestamp / MILLISECONDS_TO_SECONDS).toULong() + if (updated.timestamp != migratedTimestamp) { + updated = updated.copy(timestamp = migratedTimestamp) + wasUpdated = true + } + } + item.confirmTimestamp?.let { confirmTimestamp -> + if (confirmTimestamp > 0) { + val migratedConfirmTimestamp = (confirmTimestamp / MILLISECONDS_TO_SECONDS).toULong() + if (updated.confirmTimestamp != migratedConfirmTimestamp) { + updated = updated.copy(confirmTimestamp = migratedConfirmTimestamp) + wasUpdated = true + } + } + } + if (item.isTransfer == true) { + if (!updated.isTransfer || updated.channelId != item.channelId || + updated.transferTxId != item.transferTxId + ) { + updated = updated.copy( + isTransfer = true, + channelId = item.channelId, + transferTxId = item.transferTxId, + ) + wasUpdated = true + } + } + + if (item.boostedParents?.isNotEmpty() == true) { + applyBoostedParents(item.boostedParents, item.txId ?: item.id) + updated = updated.copy( + isBoosted = false, + boostTxIds = updated.boostTxIds.filter { it !in item.boostedParents }, + ) + wasUpdated = true + } else if (item.isBoosted == true) { + updated = updated.copy(isBoosted = true) + wasUpdated = true + } + + return if (wasUpdated) updated else null + } + + @Suppress("CyclomaticComplexMethod", "NestedBlockDepth") + private suspend fun applyOnchainMetadata(items: List) { + val onchainItems = items.filter { it.activityType == "onchain" } + + onchainItems.forEach { item -> + val txId = item.txId ?: item.id.takeIf { it.isNotEmpty() } ?: return@forEach + + val onchain = activityRepo.getOnchainActivityByTxId(txId) + if (onchain == null) { + Logger.warn("Onchain activity not found for txId: $txId", context = TAG) + return@forEach + } + + updateOnchainActivityMetadata(item, onchain)?.let { updated -> + activityRepo.updateActivity(updated.id, Activity.Onchain(updated)) + .onFailure { e -> + Logger.error( + "Failed to update onchain activity metadata for $txId: $e", + e, + context = TAG + ) + } + } + } + } + + @Suppress("LongMethod", "CyclomaticComplexMethod") + private fun convertRNWidgetPreferences( + widgetsDict: kotlinx.serialization.json.JsonObject? + ): Map { + val result = mutableMapOf() + if (widgetsDict == null) return result + + fun getBool(key: String, fallbackKey: String? = null, defaultValue: Boolean = false): Boolean { + val keys = if (fallbackKey != null) listOf(key, fallbackKey) else listOf(key) + for (k in keys) { + widgetsDict[k]?.let { element -> + when { + element is kotlinx.serialization.json.JsonPrimitive && element.isString -> { + val str = element.content.lowercase() + return str == "true" || str == "1" + } + element is kotlinx.serialization.json.JsonPrimitive -> { + return element.content.toBooleanStrictOrNull() + ?: defaultValue + } + else -> continue + } + } + } + return defaultValue + } + + val pricePrefs = widgetsDict["pricePreferences"]?.jsonObject + ?: widgetsDict["price"]?.jsonObject + pricePrefs?.let { prefs -> + val pairsArray = prefs["pairs"]?.jsonArray + ?: prefs["enabledPairs"]?.jsonArray + val mappedPairs = pairsArray?.mapNotNull { pairElement -> + pairElement.jsonPrimitive.content.replace("_", "/") + } ?: emptyList() + val selectedPairs = if (mappedPairs.isNotEmpty()) mappedPairs else listOf("BTC/USD") + + val rnPeriod = prefs["period"]?.jsonPrimitive?.content ?: "1D" + val periodMap = mapOf( + "ONE_DAY" to "1D", + "ONE_WEEK" to "1W", + "ONE_MONTH" to "1M", + "ONE_YEAR" to "1Y" + ) + val period = periodMap[rnPeriod] ?: rnPeriod + + val showSource = getBool("showSource", defaultValue = false) + val pairsJson = selectedPairs.joinToString(",", "[", "]") { "\"$it\"" } + val priceOptionsJson = + """{"selectedPairs":$pairsJson,"selectedPeriod":"$period","showSource":$showSource}""" + result["price"] = priceOptionsJson.encodeToByteArray() + } + + val weatherPrefs = widgetsDict["weatherPreferences"]?.jsonObject + ?: widgetsDict["weather"]?.jsonObject + weatherPrefs?.let { + val weatherOptions = buildJsonObject { + put("showStatus", getBool("showTitle", "showStatus", defaultValue = true)) + put("showText", getBool("showDescription", "showText", defaultValue = false)) + put("showMedian", getBool("showCurrentFee", "showMedian", defaultValue = false)) + put("showNextBlockFee", getBool("showNextBlockFee", defaultValue = false)) + } + result["weather"] = weatherOptions.toString().encodeToByteArray() + } + + val newsPrefs = widgetsDict["headlinePreferences"]?.jsonObject + ?: widgetsDict["headline"]?.jsonObject + ?: widgetsDict["news"]?.jsonObject + newsPrefs?.let { + val newsOptions = buildJsonObject { + put("showDate", getBool("showDate", "showTime", defaultValue = true)) + put("showTitle", getBool("showTitle", defaultValue = true)) + put("showSource", getBool("showSource", defaultValue = true)) + } + result["news"] = newsOptions.toString().encodeToByteArray() + } + + val blocksPrefs = widgetsDict["blocksPreferences"]?.jsonObject + ?: widgetsDict["blocks"]?.jsonObject + blocksPrefs?.let { + val blocksOptions = buildJsonObject { + put("height", getBool("height", "showBlock", defaultValue = true)) + put("time", getBool("time", "showTime", defaultValue = true)) + put("date", getBool("date", "showDate", defaultValue = true)) + put("transactionCount", getBool("transactionCount", "showTransactions", defaultValue = false)) + put("size", getBool("size", "showSize", defaultValue = false)) + put("showSource", getBool("showSource", defaultValue = false)) + } + result["blocks"] = blocksOptions.toString().encodeToByteArray() + } + + val factsPrefs = widgetsDict["factsPreferences"]?.jsonObject + ?: widgetsDict["facts"]?.jsonObject + factsPrefs?.let { + val factsOptions = buildJsonObject { + put("showSource", getBool("showSource", defaultValue = false)) + } + result["facts"] = factsOptions.toString().encodeToByteArray() + } + + return result + } +} + +data class PendingChannelMigration( + val channelManager: ByteArray, + val channelMonitors: List, +) + +@Serializable +data class RNSettings( + val enableAutoReadClipboard: Boolean? = null, + val enableSendAmountWarning: Boolean? = null, + val enableSwipeToHideBalance: Boolean? = null, + val pin: Boolean? = null, + val pinOnLaunch: Boolean? = null, + val pinOnIdle: Boolean? = null, + val pinForPayments: Boolean? = null, + val biometrics: Boolean? = null, + val rbf: Boolean? = null, + val theme: String? = null, + val unit: String? = null, + val denomination: String? = null, + val selectedCurrency: String? = null, + val selectedLanguage: String? = null, + val coinSelectAuto: Boolean? = null, + val coinSelectPreference: String? = null, + val enableDevOptions: Boolean? = null, + val enableOfflinePayments: Boolean? = null, + val enableQuickpay: Boolean? = null, + val quickpayAmount: Int? = null, + val showWidgets: Boolean? = null, + val showWidgetTitles: Boolean? = null, + val transactionSpeed: String? = null, + val customFeeRate: Int? = null, + val hideBalance: Boolean? = null, + val hideBalanceOnOpen: Boolean? = null, + val quickpayIntroSeen: Boolean? = null, + val shopIntroSeen: Boolean? = null, + val transferIntroSeen: Boolean? = null, + val spendingIntroSeen: Boolean? = null, + val savingsIntroSeen: Boolean? = null, +) + +@Serializable +data class RNMetadata( + val tags: Map>? = null, + val lastUsedTags: List? = null, +) + +@Serializable +data class RNActivityState( + val items: List? = null, +) + +@Serializable +data class RNActivityItem( + val id: String, + val activityType: String, + val txType: String, + val txId: String? = null, + val value: Long, + val fee: Long? = null, + val feeRate: Long? = null, + val address: String? = null, + val confirmed: Boolean? = null, + val timestamp: Long, + val isBoosted: Boolean? = null, + val isTransfer: Boolean? = null, + val exists: Boolean? = null, + val confirmTimestamp: Long? = null, + val channelId: String? = null, + val transferTxId: String? = null, + val status: String? = null, + val message: String? = null, + val preimage: String? = null, + val boostedParents: List? = null, +) + +@Serializable +data class RNLightningState( + val nodes: Map? = null, +) + +@Serializable +data class RNLightningNode( + val channels: Map>? = null, +) + +@Serializable +data class RNChannel( + @SerialName("channel_id") + val channelId: String, + val status: String? = null, + val createdAt: Long? = null, + @SerialName("counterparty_node_id") + val counterpartyNodeId: String? = null, + @SerialName("funding_txid") + val fundingTxid: String? = null, + @SerialName("channel_value_satoshis") + val channelValueSatoshis: ULong? = null, + @SerialName("balance_sat") + val balanceSat: ULong? = null, + @SerialName("claimable_balances") + val claimableBalances: List? = null, + @SerialName("outbound_capacity_sat") + val outboundCapacitySat: ULong? = null, + @SerialName("inbound_capacity_sat") + val inboundCapacitySat: ULong? = null, + @SerialName("is_usable") + val isUsable: Boolean? = null, + @SerialName("is_channel_ready") + val isChannelReady: Boolean? = null, + val confirmations: UInt? = null, + @SerialName("confirmations_required") + val confirmationsRequired: UInt? = null, + @SerialName("short_channel_id") + val shortChannelId: String? = null, + val closureReason: String? = null, + @SerialName("unspendable_punishment_reserve") + val unspendablePunishmentReserve: ULong? = null, + @SerialName("counterparty_unspendable_punishment_reserve") + val counterpartyUnspendablePunishmentReserve: ULong? = null, +) + +@Serializable +data class RNClaimableBalance( + @SerialName("amount_satoshis") + val amountSatoshis: ULong? = null, + val type: String? = null, +) + +@Serializable +data class RNWidgets( + val onboardedWidgets: Boolean? = null, + val sortOrder: List? = null, +) + +data class RNWidgetsWithOptions( + val widgets: RNWidgets, + val widgetOptions: Map, +) + +private enum class RNKeychainKey(val service: String) { + MNEMONIC("wallet0"), + PASSPHRASE("wallet0passphrase"), + PIN("pin"), } diff --git a/app/src/main/java/to/bitkit/services/MmkvParser.kt b/app/src/main/java/to/bitkit/services/MmkvParser.kt new file mode 100644 index 000000000..07c7ecf1e --- /dev/null +++ b/app/src/main/java/to/bitkit/services/MmkvParser.kt @@ -0,0 +1,75 @@ +package to.bitkit.services + +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class MmkvParser(private val data: ByteArray) { + companion object { + private const val HEADER_SIZE = 8 + private const val CONTENT_SIZE_BYTES = 4 + private const val VARINT_DATA_MASK = 0x7F + private const val VARINT_CONTINUE_MASK = 0x80 + private const val VARINT_SHIFT_INCREMENT = 7 + private const val VARINT_MAX_SHIFT = 64 + } + + @Suppress("LoopWithTooManyJumpStatements") + fun parse(): Map { + if (data.size < HEADER_SIZE) return emptyMap() + + val contentSize = ByteBuffer.wrap(data, 0, CONTENT_SIZE_BYTES) + .order(ByteOrder.LITTLE_ENDIAN) + .int + val endOffset = minOf(HEADER_SIZE + contentSize, data.size) + + val result = mutableMapOf() + var offset = HEADER_SIZE + + while (offset < endOffset) { + val (keyLength, keyLengthBytes) = readVarint(offset, endOffset) ?: break + offset += keyLengthBytes + + if (offset + keyLength > endOffset) break + val keyData = data.sliceArray(offset until offset + keyLength) + val key = String(keyData, Charsets.UTF_8) + offset += keyLength + + val (valueLength, valueLengthBytes) = readVarint(offset, endOffset) ?: break + offset += valueLengthBytes + + if (offset + valueLength > endOffset) break + val valueData = data.sliceArray(offset until offset + valueLength) + + val value = runCatching { String(valueData, Charsets.UTF_8) } + .getOrElse { String(valueData, Charsets.ISO_8859_1) } + result[key] = value + offset += valueLength + } + + return result + } + + private fun readVarint(offset: Int, endOffset: Int): Pair? { + var result = 0 + var shift = 0 + var bytesRead = 0 + var currentOffset = offset + + while (currentOffset < endOffset) { + val byte = data[currentOffset].toInt() + result = result or ((byte and VARINT_DATA_MASK) shl shift) + + bytesRead++ + currentOffset++ + + if ((byte and VARINT_CONTINUE_MASK) == 0) { + return Pair(result, bytesRead) + } + + shift += VARINT_SHIFT_INCREMENT + if (shift >= VARINT_MAX_SHIFT) return null + } + + return null + } +} diff --git a/app/src/main/java/to/bitkit/services/RNBackupClient.kt b/app/src/main/java/to/bitkit/services/RNBackupClient.kt new file mode 100644 index 000000000..fc1062e6c --- /dev/null +++ b/app/src/main/java/to/bitkit/services/RNBackupClient.kt @@ -0,0 +1,362 @@ +package to.bitkit.services + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.http.isSuccess +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.bouncycastle.crypto.digests.SHA512Digest +import org.bouncycastle.crypto.generators.PKCS5S2ParametersGenerator +import org.bouncycastle.crypto.params.KeyParameter +import org.ldk.structs.KeysManager +import org.lightningdevkit.ldknode.Network +import to.bitkit.data.keychain.Keychain +import to.bitkit.di.IoDispatcher +import to.bitkit.di.json +import to.bitkit.env.Env +import to.bitkit.utils.AppError +import to.bitkit.utils.Crypto +import to.bitkit.utils.Logger +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class RNBackupClient @Inject constructor( + private val httpClient: HttpClient, + private val crypto: Crypto, + private val keychain: Keychain, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val json: Json, +) { + companion object { + private const val TAG = "RNBackup" + private const val VERSION = "v1" + private const val SIGNED_MESSAGE_PREFIX = "react-native-ldk backup server auth:" + private const val GCM_IV_LENGTH = 12 + private const val GCM_TAG_LENGTH = 16 + } + + @Volatile + private var cachedBearer: AuthBearerResponse? = null + + private val authMutex = Mutex() + + suspend fun listFiles(fileGroup: String? = "ldk"): RNBackupListResponse? = withContext(ioDispatcher) { + runCatching { + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) + ?: throw RNBackupError.NotSetup + val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) + + val bearer = authenticate(mnemonic, passphrase) + val url = buildUrl("list", fileGroup = fileGroup, network = getNetworkString()) + val response: HttpResponse = httpClient.get(url) { + header("Authorization", bearer.bearer) + } + + if (!response.status.isSuccess()) { + throw RNBackupError.RequestFailed("Status: ${response.status.value}") + } + + response.body() + }.onFailure { e -> + Logger.error("Failed to list files for fileGroup=$fileGroup", e, context = TAG) + }.getOrNull() + } + + suspend fun retrieve(label: String, fileGroup: String? = null): ByteArray? = withContext(ioDispatcher) { + runCatching { + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) + ?: throw RNBackupError.NotSetup + val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) + + val bearer = authenticate(mnemonic, passphrase) + val url = buildUrl("retrieve", label = label, fileGroup = fileGroup, network = getNetworkString()) + val response: HttpResponse = httpClient.get(url) { + header("Authorization", bearer.bearer) + } + + if (!response.status.isSuccess()) { + throw RNBackupError.RequestFailed("Status: ${response.status.value}") + } + + val encryptedData = response.body() + if (encryptedData.isEmpty()) throw RNBackupError.RequestFailed("Retrieved data is empty") + + val encryptionKey = deriveEncryptionKey(mnemonic, passphrase) + decrypt(encryptedData, encryptionKey).also { + if (it.isEmpty()) throw RNBackupError.DecryptFailed("Decrypted data is empty") + } + }.onFailure { e -> + Logger.error("Failed to retrieve $label", e, context = TAG) + }.getOrNull() + } + + suspend fun retrieveChannelMonitor(channelId: String): ByteArray? = withContext(ioDispatcher) { + runCatching { + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) + ?: throw RNBackupError.NotSetup + val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) + + val bearer = authenticate(mnemonic, passphrase) + val url = buildUrl( + method = "retrieve", + label = "channel_monitor", + fileGroup = "ldk", + channelId = channelId, + network = getNetworkString(), + ) + val response: HttpResponse = httpClient.get(url) { + header("Authorization", bearer.bearer) + } + + if (!response.status.isSuccess()) { + throw RNBackupError.RequestFailed("Status: ${response.status.value}") + } + + val encryptedData = response.body() + if (encryptedData.isEmpty()) throw RNBackupError.RequestFailed("Retrieved data is empty") + + val encryptionKey = deriveEncryptionKey(mnemonic, passphrase) + decrypt(encryptedData, encryptionKey).also { + if (it.isEmpty()) throw RNBackupError.DecryptFailed("Decrypted data is empty") + } + }.onFailure { e -> + Logger.error("Failed to retrieve channel monitor $channelId", e, context = TAG) + }.getOrNull() + } + + suspend fun hasBackup(): Boolean = withContext(ioDispatcher) { + runCatching { + val ldkFiles = listFiles(fileGroup = "ldk") + val bitkitFiles = listFiles(fileGroup = "bitkit") + val hasLdkFiles = !ldkFiles?.list.isNullOrEmpty() || !ldkFiles?.channelMonitors.isNullOrEmpty() + val hasBitkitFiles = !bitkitFiles?.list.isNullOrEmpty() + hasLdkFiles || hasBitkitFiles + }.onFailure { e -> + Logger.error("Failed to check if backup exists", e, context = TAG) + }.getOrDefault(false) + } + + suspend fun getLatestBackupTimestamp(): ULong? = withContext(ioDispatcher) { + runCatching { + val bitkitFiles = listFiles(fileGroup = "bitkit")?.list ?: return@withContext null + if (bitkitFiles.isEmpty()) return@withContext null + + @Serializable + data class BackupMetadata(val timestamp: Long? = null) + + @Serializable + data class BackupWithMetadata(val metadata: BackupMetadata? = null) + + val labels = listOf( + "bitkit_settings", + "bitkit_metadata", + "bitkit_widgets", + "bitkit_lightning_activity", + "bitkit_wallet", + "bitkit_blocktank_orders", + ) + + var latestTimestamp: ULong? = null + for (label in labels) { + if ("$label.bin" !in bitkitFiles) continue + + val data = retrieve(label, fileGroup = "bitkit") ?: continue + val timestamp = runCatching { + json.decodeFromString(String(data)).metadata?.timestamp + }.getOrNull() ?: continue + + val ts = (timestamp / 1000).toULong() + latestTimestamp = maxOf(latestTimestamp ?: 0uL, ts) + } + latestTimestamp + }.onFailure { e -> + Logger.error("Failed to get latest backup timestamp", e, context = TAG) + }.getOrNull() + } + + private fun buildUrl( + method: String, + label: String? = null, + fileGroup: String? = null, + channelId: String? = null, + network: String, + ): String { + var url = "${Env.rnBackupServerHost}/$VERSION/$method?network=$network" + label?.let { url += "&label=$it" } + fileGroup?.let { url += "&fileGroup=$it" } + channelId?.let { url += "&channelId=$it" } + return url + } + + private suspend fun authenticate(mnemonic: String, passphrase: String?): AuthBearerResponse { + fun isBearerValid(bearer: AuthBearerResponse): Boolean { + val now = System.currentTimeMillis() / 1000.0 + return bearer.expires / 1000.0 > now + } + + cachedBearer?.takeIf { isBearerValid(it) }?.let { return it } + + return authMutex.withLock { + cachedBearer?.takeIf { isBearerValid(it) }?.let { return@withLock it } + cachedBearer = null + + val secretKey = deriveSigningKey(mnemonic, passphrase) + val pubKeyHex = crypto.getPublicKey(secretKey).toHex() + val networkString = getNetworkString() + val timestamp = System.currentTimeMillis() / 1000 + + val challengeBody = json.encodeToString( + buildJsonObject { + put("timestamp", timestamp.toString()) + put("signature", signMessage(timestamp.toString(), secretKey)) + } + ) + val challengeResponse: HttpResponse = httpClient.post( + "${Env.rnBackupServerHost}/$VERSION/auth/challenge?network=$networkString" + ) { + header("Public-Key", pubKeyHex) + header("Content-Type", "application/json") + setBody(challengeBody) + } + + if (!challengeResponse.status.isSuccess()) { + throw RNBackupError.AuthFailed + } + + val challengeResult = challengeResponse.body() + val authBody = json.encodeToString( + buildJsonObject { + put("signature", signMessage(challengeResult.challenge, secretKey)) + } + ) + val authResponse: HttpResponse = httpClient.post( + "${Env.rnBackupServerHost}/$VERSION/auth/response?network=$networkString" + ) { + header("Public-Key", pubKeyHex) + header("Content-Type", "application/json") + setBody(authBody) + } + + if (!authResponse.status.isSuccess()) { + throw RNBackupError.AuthFailed + } + + authResponse.body().also { cachedBearer = it } + } + } + + private fun getNetworkString(): String { + return when (Env.network) { + Network.BITCOIN -> "bitcoin" + Network.TESTNET -> "testnet" + Network.REGTEST -> "regtest" + Network.SIGNET -> "signet" + } + } + + private fun signMessage(message: String, privateKey: ByteArray): String { + val fullMessage = "$SIGNED_MESSAGE_PREFIX$message" + return crypto.sign(fullMessage, privateKey) + } + + private fun deriveSigningKey(mnemonic: String, passphrase: String?): ByteArray { + val bip39Seed = deriveSeed(mnemonic, passphrase) + val bip32Seed = deriveMasterKey(bip39Seed) + val seconds = System.currentTimeMillis() / 1000L + val nanoSeconds = ((System.currentTimeMillis() % 1000) * 1_000_000).toInt() + + return runCatching { + val keysManager = KeysManager.of(bip32Seed, seconds, nanoSeconds) + val method = keysManager.javaClass.getMethod("get_node_secret_key") + when (val nodeSecretKey = method.invoke(keysManager)) { + is ByteArray -> nodeSecretKey + is List<*> -> nodeSecretKey.map { (it as UByte).toByte() }.toByteArray() + else -> throw ClassCastException("Unexpected type: ${nodeSecretKey?.javaClass?.name}") + } + }.getOrElse { bip32Seed } + } + + private fun deriveEncryptionKey(mnemonic: String, passphrase: String?): ByteArray { + // Match iOS: use the same node secret key as signing key for encryption + // iOS uses SymmetricKey(data: secretKey) where secretKey is the node secret key + return deriveSigningKey(mnemonic, passphrase) + } + + private fun deriveSeed(mnemonic: String, passphrase: String?): ByteArray { + val mnemonicBytes = mnemonic.toByteArray(Charsets.UTF_8) + val salt = ("mnemonic" + (passphrase ?: "")).toByteArray(Charsets.UTF_8) + val generator = PKCS5S2ParametersGenerator(SHA512Digest()) + generator.init(mnemonicBytes, salt, 2048) + return (generator.generateDerivedParameters(512) as KeyParameter).key + } + + private fun deriveMasterKey(seed: ByteArray): ByteArray { + val hmac = javax.crypto.Mac.getInstance("HmacSHA512") + val keySpec = javax.crypto.spec.SecretKeySpec("Bitcoin seed".toByteArray(), "HmacSHA512") + hmac.init(keySpec) + val i = hmac.doFinal(seed) + return i.sliceArray(0 until 32) + } + + private fun decrypt(blob: ByteArray, encryptionKey: ByteArray): ByteArray { + if (blob.size < GCM_IV_LENGTH + GCM_TAG_LENGTH) { + throw RNBackupError.DecryptFailed("Data too short") + } + + val nonce = blob.sliceArray(0 until GCM_IV_LENGTH) + val tag = blob.sliceArray(blob.size - GCM_TAG_LENGTH until blob.size) + val ciphertext = blob.sliceArray(GCM_IV_LENGTH until blob.size - GCM_TAG_LENGTH) + + val key = SecretKeySpec(encryptionKey, "AES") + val spec = GCMParameterSpec(GCM_TAG_LENGTH * 8, nonce) + val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply { + init(Cipher.DECRYPT_MODE, key, spec) + } + + return cipher.doFinal(ciphertext + tag) + } + + private fun ByteArray.toHex(): String { + return this.joinToString("") { "%02x".format(it) } + } +} + +@Serializable +private data class AuthChallengeResponse( + val challenge: String, +) + +@Serializable +private data class AuthBearerResponse( + val bearer: String, + val expires: Long, +) + +@Serializable +data class RNBackupListResponse( + val list: List = emptyList(), + @SerialName("channel_monitors") val channelMonitors: List = emptyList(), +) + +sealed class RNBackupError(message: String) : AppError(message) { + data object NotSetup : RNBackupError("RN backup client not setup") + data object AuthFailed : RNBackupError("Authentication failed") + data class RequestFailed(override val message: String) : RNBackupError(message) + data class DecryptFailed(override val message: String) : RNBackupError(message) +} diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 0220b40ed..94ca412df 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -271,15 +271,23 @@ fun ContentView( var walletIsInitializing by remember { mutableStateOf(nodeLifecycleState == NodeLifecycleState.Initializing) } var walletInitShouldFinish by remember { mutableStateOf(false) } + val restoreState by walletViewModel.restoreState.collectAsStateWithLifecycle() + val isRestoringFromRNRemoteBackup by walletViewModel.isRestoringFromRNRemoteBackup.collectAsStateWithLifecycle() + var restoreRetryCount by remember { mutableIntStateOf(0) } + // React to nodeLifecycleState changes - LaunchedEffect(nodeLifecycleState) { + LaunchedEffect(nodeLifecycleState, restoreState, isRestoringFromRNRemoteBackup) { when (nodeLifecycleState) { NodeLifecycleState.Initializing -> { walletIsInitializing = true } NodeLifecycleState.Running -> { - walletInitShouldFinish = true + val restoreComplete = restoreState !is RestoreState.InProgress + val metadataComplete = !isRestoringFromRNRemoteBackup + if (restoreComplete && metadataComplete) { + walletInitShouldFinish = true + } } is NodeLifecycleState.ErrorStarting -> { @@ -290,9 +298,6 @@ fun ContentView( } } - val restoreState = walletViewModel.restoreState - var restoreRetryCount by remember { mutableIntStateOf(0) } - if (walletIsInitializing) { // TODO ADAPT THIS LOGIC TO WORK WITH LightningNodeService if (nodeLifecycleState is NodeLifecycleState.ErrorStarting) { diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 0ae430519..e4a623e06 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -43,6 +43,7 @@ import to.bitkit.ui.onboarding.OnboardingSlidesScreen import to.bitkit.ui.onboarding.RestoreWalletScreen import to.bitkit.ui.onboarding.TermsOfUseScreen import to.bitkit.ui.onboarding.WarningMultipleDevicesScreen +import to.bitkit.ui.screens.MigrationLoadingScreen import to.bitkit.ui.screens.SplashScreen import to.bitkit.ui.sheets.ForgotPinSheet import to.bitkit.ui.sheets.NewTransactionSheet @@ -98,6 +99,7 @@ class MainActivity : FragmentActivity() { val walletExists by walletViewModel.walletState .map { it.walletExists } .collectAsStateWithLifecycle(initialValue = walletViewModel.walletExists) + val isShowingMigrationLoading by walletViewModel.isShowingMigrationLoading.collectAsStateWithLifecycle() val hazeState = rememberHazeState(blurEnabled = true) LaunchedEffect( @@ -110,7 +112,9 @@ class MainActivity : FragmentActivity() { } } - if (!walletViewModel.walletExists && !isRecoveryMode) { + if (isShowingMigrationLoading && !isRecoveryMode) { + MigrationLoadingScreen(isVisible = true) + } else if (!walletViewModel.walletExists && !isRecoveryMode) { OnboardingNav( startupNavController = rememberNavController(), scope = scope, diff --git a/app/src/main/java/to/bitkit/ui/onboarding/TermsOfUseScreen.kt b/app/src/main/java/to/bitkit/ui/onboarding/TermsOfUseScreen.kt index 30b60c475..920a82d54 100644 --- a/app/src/main/java/to/bitkit/ui/onboarding/TermsOfUseScreen.kt +++ b/app/src/main/java/to/bitkit/ui/onboarding/TermsOfUseScreen.kt @@ -135,7 +135,6 @@ private fun TermsText( } } - @Preview(showSystemUi = true) @Composable private fun TermsPreview() { diff --git a/app/src/main/java/to/bitkit/ui/screens/MigrationLoadingScreen.kt b/app/src/main/java/to/bitkit/ui/screens/MigrationLoadingScreen.kt new file mode 100644 index 000000000..8232df731 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/MigrationLoadingScreen.kt @@ -0,0 +1,77 @@ +package to.bitkit.ui.screens + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.Display +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors + +@Composable +fun MigrationLoadingScreen(isVisible: Boolean = true) { + AnimatedVisibility( + visible = isVisible, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(24.dp) + ) { + CircularProgressIndicator( + modifier = Modifier.padding(16.dp), + color = Color.White, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Display( + text = "Updating Wallet", + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + color = Color.White, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + BodyM( + text = "Please wait while we update the app...", + color = Colors.White64, + textAlign = TextAlign.Center, + ) + } + } + } +} + +@Preview +@Composable +fun MigrationLoadingScreenPreview() { + AppThemeSurface { + MigrationLoadingScreen() + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt index 06afb3e21..0e2a543f7 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt @@ -609,13 +609,10 @@ private fun ActivityDetailContent( is Activity.Onchain -> { val activity = item.v1 if (activity.isBoosted && activity.boostTxIds.isNotEmpty()) { - val hasCPFP = activity.boostTxIds.any { boostTxDoesExist[it] == true } - if (hasCPFP) { + if (activity.txType == PaymentType.SENT) { true - } else if (activity.txType == PaymentType.SENT) { - activity.boostTxIds.any { boostTxDoesExist[it] == false } } else { - false + activity.boostTxIds.any { boostTxDoesExist[it] == true } } } else { false @@ -948,20 +945,13 @@ private fun isBoostCompleted( activity: OnchainActivity, boostTxDoesExist: Map, ): Boolean { - // If boostTxIds is empty, boost is in progress (RBF case) if (activity.boostTxIds.isEmpty()) return true - // Check if CPFP boost is completed - val hasCPFP = activity.boostTxIds.any { boostTxDoesExist[it] == true } - if (hasCPFP) return true - - // For sent transactions, check if RBF boost is completed if (activity.txType == PaymentType.SENT) { - val hasRBF = activity.boostTxIds.any { boostTxDoesExist[it] == false } - if (hasRBF) return true + return true + } else { + return activity.boostTxIds.any { boostTxDoesExist[it] == true } } - - return false } // TODO remove this method after transifex update diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt index 7ac978f5f..f6b8e16f0 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt @@ -335,8 +335,7 @@ private fun ColumnScope.OnchainDetails( val boostTxIds = onchain.v1.boostTxIds if (boostTxIds.isNotEmpty()) { boostTxIds.forEachIndexed { index, boostedTxId -> - val boostTxDoesExistValue = boostTxDoesExist[boostedTxId] ?: true - val isRbf = !boostTxDoesExistValue + val isRbf = onchain.v1.txType == PaymentType.SENT || !(boostTxDoesExist[boostedTxId] ?: true) Section( title = stringResource( if (isRbf) R.string.wallet__activity_boosted_rbf else R.string.wallet__activity_boosted_cpfp diff --git a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt index 12025db7b..381365c44 100644 --- a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt @@ -11,6 +11,7 @@ import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.LightningRepo import to.bitkit.services.CoreService +import to.bitkit.services.MigrationService import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton @@ -29,6 +30,7 @@ class WipeWalletUseCase @Inject constructor( private val activityRepo: ActivityRepo, private val lightningRepo: LightningRepo, private val firebaseMessaging: FirebaseMessaging, + private val migrationService: MigrationService, ) { @Suppress("TooGenericExceptionCaught") suspend operator fun invoke( @@ -54,6 +56,8 @@ class WipeWalletUseCase @Inject constructor( activityRepo.resetState() resetWalletState() + migrationService.markMigrationChecked() + return lightningRepo.wipeStorage(walletIndex) .onSuccess { onSuccess() diff --git a/app/src/main/java/to/bitkit/utils/Crypto.kt b/app/src/main/java/to/bitkit/utils/Crypto.kt index 747f6c683..dbce66c8e 100644 --- a/app/src/main/java/to/bitkit/utils/Crypto.kt +++ b/app/src/main/java/to/bitkit/utils/Crypto.kt @@ -1,5 +1,11 @@ package to.bitkit.utils +import org.bouncycastle.asn1.sec.SECNamedCurves +import org.bouncycastle.asn1.x9.X9ECParameters +import org.bouncycastle.crypto.params.ECDomainParameters +import org.bouncycastle.crypto.params.ECPrivateKeyParameters +import org.bouncycastle.crypto.params.ParametersWithRandom +import org.bouncycastle.crypto.signers.ECDSASigner import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPrivateKey import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPublicKey import org.bouncycastle.jce.ECNamedCurveTable @@ -10,10 +16,12 @@ import org.bouncycastle.jce.spec.ECPrivateKeySpec import org.bouncycastle.jce.spec.ECPublicKeySpec import org.bouncycastle.util.BigIntegers import to.bitkit.ext.fromHex +import to.bitkit.ext.toHex import java.math.BigInteger import java.security.KeyFactory import java.security.KeyPairGenerator import java.security.MessageDigest +import java.security.SecureRandom import java.security.Security import javax.crypto.Cipher import javax.crypto.KeyAgreement @@ -135,6 +143,88 @@ class Crypto @Inject constructor() { } } + fun sign(message: String, privateKey: ByteArray): String = runCatching { + val lightningPrefix = "Lightning Signed Message:" + val prefixedMessage = lightningPrefix + message + val hash1 = MessageDigest.getInstance("SHA-256").digest(prefixedMessage.toByteArray()) + val messageHash = MessageDigest.getInstance("SHA-256").digest(hash1) + + val curve: X9ECParameters = SECNamedCurves.getByName("secp256k1") + val privateKeyBigInt = BigInteger(1, privateKey) + val domainParams = ECDomainParameters(curve.curve, curve.g, curve.n, curve.h) + val privateKeyParams = ECPrivateKeyParameters(privateKeyBigInt, domainParams) + + val signer = ECDSASigner() + signer.init(true, ParametersWithRandom(privateKeyParams, SecureRandom())) + + val components = signer.generateSignature(messageHash) + val r = components[0] + val s = components[1] + + val n = curve.n + val halfOrder = n.shiftRight(1) + if (s > halfOrder) { + val s2 = n.subtract(s) + val recId = calculateRecoveryId(r, s2, messageHash, privateKeyBigInt, curve) + formatSignature(recId, r, s2) + } else { + val recId = calculateRecoveryId(r, s, messageHash, privateKeyBigInt, curve) + formatSignature(recId, r, s) + } + }.getOrElse { throw CryptoError.SigningFailed } + + fun getPublicKey(privateKey: ByteArray): ByteArray = runCatching { + val keyFactory = KeyFactory.getInstance("EC", "BC") + val privateKeySpec = ECPrivateKeySpec(BigInteger(1, privateKey), params) + val privateKeyObj = keyFactory.generatePrivate(privateKeySpec) + + val publicKeyPoint = params.g.multiply((privateKeyObj as ECPrivateKey).d) + publicKeyPoint.getEncoded(true) + }.getOrElse { throw CryptoError.PublicKeyCreationFailed } + + private fun calculateRecoveryId( + r: BigInteger, + s: BigInteger, + messageHash: ByteArray, + privateKey: BigInteger, + curve: X9ECParameters, + ): Int { + val expectedPublicKey = curve.g.multiply(privateKey) + val n = curve.n + val e = BigInteger(1, messageHash) + val rInv = r.modInverse(n) + + for (recId in 0..3) { + try { + val x = if (recId >= 2) r.add(n) else r + val xBytes = BigIntegers.asUnsignedByteArray(32, x) + + val yEven = curve.curve.decodePoint(byteArrayOf(0x02) + xBytes) + val yOdd = curve.curve.decodePoint(byteArrayOf(0x03) + xBytes) + + val recoveryPoint = if (recId % 2 == 0) yEven else yOdd + + val candidatePublicKey = recoveryPoint.multiply(s.multiply(rInv).mod(n)) + .subtract(curve.g.multiply(e.multiply(rInv).mod(n))) + + if (candidatePublicKey.equals(expectedPublicKey)) { + return recId + } + } catch (_: Exception) { + continue + } + } + throw CryptoError.SigningFailed + } + + private fun formatSignature(recId: Int, r: BigInteger, s: BigInteger): String { + val recIdByte = (recId + 31).toByte() + val rBytes = BigIntegers.asUnsignedByteArray(32, r) + val sBytes = BigIntegers.asUnsignedByteArray(32, s) + val signature = byteArrayOf(recIdByte) + rBytes + sBytes + return signature.toHex() + } + private fun sha256d(input: ByteArray): ByteArray { return MessageDigest.getInstance("SHA-256").run { digest(digest(input)) } } @@ -145,4 +235,6 @@ sealed class CryptoError(message: String) : AppError(message) { data object SecurityProviderSetupFailed : CryptoError("Security provider setup failed") data object KeypairGenerationFailed : CryptoError("Keypair generation failed") data object DecryptionFailed : CryptoError("Decryption failed") + data object SigningFailed : CryptoError("Signing failed") + data object PublicKeyCreationFailed : CryptoError("Public key creation failed") } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 59ce94a52..38b22682b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -29,6 +29,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel @@ -46,6 +47,8 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.lightningdevkit.ldknode.ChannelDataMigration import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.SpendableUtxo @@ -135,6 +138,7 @@ class AppViewModel @Inject constructor( private val notifyPaymentReceivedHandler: NotifyPaymentReceivedHandler, private val cacheStore: CacheStore, private val transferRepo: TransferRepo, + private val migrationService: to.bitkit.services.MigrationService, ) : ViewModel() { val healthState = healthRepo.healthState @@ -175,6 +179,7 @@ class AppViewModel @Inject constructor( private var timedSheetsScope: CoroutineScope? = null private var timedSheetQueue: List = emptyList() private var currentTimedSheet: TimedSheetType? = null + private var isCompletingMigration = false fun setShowForgotPin(value: Boolean) { _showForgotPinSheet.value = value @@ -215,9 +220,9 @@ class AppViewModel @Inject constructor( } viewModelScope.launch { // Delays are required for auth check on launch functionality - delay(1000) + delay(AUTH_CHECK_INITIAL_DELAY_MS) resetIsAuthenticatedState() - delay(500) + delay(AUTH_CHECK_SPLASH_DELAY_MS) splashVisible = false } viewModelScope.launch { @@ -232,6 +237,27 @@ class AppViewModel @Inject constructor( checkTimedSheets() } } + + viewModelScope.launch { + migrationService.isShowingMigrationLoading.collect { isShowing -> + if (isShowing) { + @Suppress("SwallowedException") + try { + withTimeout(MIGRATION_LOADING_TIMEOUT_MS) { + migrationService.isShowingMigrationLoading.first { !it } + } + } catch (e: TimeoutCancellationException) { + if (!isCompletingMigration) { + Logger.warn( + "Migration loading screen timeout, completing migration anyway", + context = TAG + ) + completeMigration() + } + } + } + } + } } private fun observeLdkNodeEvents() { @@ -292,7 +318,122 @@ class AppViewModel @Inject constructor( walletRepo.syncBalances() } - private fun handleSyncCompleted() = walletRepo.debounceSyncByEvent() + private suspend fun handleSyncCompleted() { + val isShowingLoading = migrationService.isShowingMigrationLoading.value + val isRestoringRemote = migrationService.isRestoringFromRNRemoteBackup.value + + when { + isShowingLoading && !isCompletingMigration -> completeMigration() + isRestoringRemote -> completeRNRemoteBackupRestore() + !isShowingLoading && !isCompletingMigration -> walletRepo.debounceSyncByEvent() + else -> Unit + } + } + + private suspend fun completeRNRemoteBackupRestore() { + lightningRepo.getPayments().onSuccess { activityRepo.syncLdkNodePayments(it) } + migrationService.reapplyMetadataAfterSync() + activityRepo.syncActivities() + migrationService.setRestoringFromRNRemoteBackup(false) + migrationService.setShowingMigrationLoading(false) + } + + private fun buildChannelMigrationIfAvailable(): ChannelDataMigration? { + val migration = migrationService.peekPendingChannelMigration() ?: return null + return ChannelDataMigration( + channelManager = migration.channelManager.map { it.toUByte() }, + channelMonitors = migration.channelMonitors.map { monitor -> monitor.map { it.toUByte() } }, + ) + } + + private suspend fun completeMigration() { + if (isCompletingMigration) return + isCompletingMigration = true + + try { + lightningRepo.getPayments().onSuccess { payments -> + activityRepo.syncLdkNodePayments(payments) + }.onFailure { e -> + Logger.warn("Failed to get payments during migration: $e", e, context = TAG) + } + activityRepo.markAllUnseenActivitiesAsSeen() + + val channelMigration = buildChannelMigrationIfAvailable() + lightningRepo.stop().onFailure { + Logger.error("Failed to stop node during migration restart", it, context = TAG) + } + delay(MIGRATION_NODE_RESTART_DELAY_MS) + lightningRepo.start(channelMigration = channelMigration, shouldRetry = false) + .onSuccess { + migrationService.consumePendingChannelMigration() + walletRepo.syncNodeAndWallet() + .onSuccess { + finishMigrationSuccessfully() + } + .onFailure { e -> + Logger.warn("Sync failed after restart during migration: $e", e, context = TAG) + finishMigrationWithFallbackSync() + } + } + .onFailure { e -> + Logger.error("Failed to restart node after migration: $e", e, context = TAG) + finishMigrationWithError() + } + } catch (e: Exception) { + Logger.error("Migration completion error: $e", e, context = TAG) + finishMigrationWithError() + } finally { + isCompletingMigration = false + } + } + + private suspend fun finishMigrationSuccessfully() { + lightningRepo.getPayments().onSuccess { payments -> + activityRepo.syncLdkNodePayments(payments) + } + transferRepo.syncTransferStates() + migrationService.reapplyMetadataAfterSync() + + migrationService.setShowingMigrationLoading(false) + delay(MIGRATION_AUTH_RESET_DELAY_MS) + resetIsAuthenticatedStateInternal() + + toast( + type = Toast.ToastType.SUCCESS, + title = "Migration Complete", + description = "Your wallet has been successfully migrated" + ) + } + + private suspend fun finishMigrationWithFallbackSync() { + walletRepo.syncBalances() + lightningRepo.getPayments().onSuccess { payments -> + activityRepo.syncLdkNodePayments(payments) + } + transferRepo.syncTransferStates() + migrationService.reapplyMetadataAfterSync() + + migrationService.setShowingMigrationLoading(false) + delay(MIGRATION_AUTH_RESET_DELAY_MS) + resetIsAuthenticatedStateInternal() + + toast( + type = Toast.ToastType.SUCCESS, + title = "Migration Complete", + description = "Your wallet has been successfully migrated" + ) + } + + private suspend fun finishMigrationWithError() { + migrationService.setShowingMigrationLoading(false) + delay(MIGRATION_AUTH_RESET_DELAY_MS) + resetIsAuthenticatedStateInternal() + toast( + type = Toast.ToastType.ERROR, + title = "Migration Warning", + description = "Migration completed but node restart failed. Please restart the app." + ) + } private suspend fun handleOnchainTransactionConfirmed(event: Event.OnchainTransactionConfirmed) { activityRepo.handleOnchainTransactionConfirmed(event.txid, event.details) @@ -1638,13 +1779,15 @@ class AppViewModel @Inject constructor( // endregion // region security + private suspend fun resetIsAuthenticatedStateInternal() { + val settings = settingsStore.data.first() + val needsAuth = settings.isPinEnabled && settings.isPinOnLaunchEnabled + _isAuthenticated.value = !needsAuth + } + fun resetIsAuthenticatedState() { viewModelScope.launch { - val settings = settingsStore.data.first() - val needsAuth = settings.isPinEnabled && settings.isPinOnLaunchEnabled - if (!needsAuth) { - _isAuthenticated.value = true - } + resetIsAuthenticatedStateInternal() } } @@ -2001,6 +2144,11 @@ class AppViewModel @Inject constructor( private const val MAX_BALANCE_FRACTION = 0.5 private const val MAX_FEE_AMOUNT_RATIO = 0.5 private const val SCREEN_TRANSITION_DELAY_MS = 300L + private const val MIGRATION_LOADING_TIMEOUT_MS = 300_000L + private const val MIGRATION_NODE_RESTART_DELAY_MS = 500L + private const val MIGRATION_AUTH_RESET_DELAY_MS = 500L + private const val AUTH_CHECK_INITIAL_DELAY_MS = 1000L + private const val AUTH_CHECK_SPLASH_DELAY_MS = 500L /**How high the balance must be to show this warning to the user (in USD)*/ private const val BALANCE_THRESHOLD_USD = 500L diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index ac15b99ef..71d1c5ad2 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -8,13 +8,18 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import org.lightningdevkit.ldknode.ChannelDataMigration import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.NodeStatus import org.lightningdevkit.ldknode.PeerDetails @@ -28,6 +33,7 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.RecoveryModeException import to.bitkit.repositories.SyncSource import to.bitkit.repositories.WalletRepo +import to.bitkit.services.MigrationService import to.bitkit.ui.onboarding.LOADING_MS import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger @@ -35,6 +41,7 @@ import to.bitkit.utils.isTxSyncTimeout import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds @HiltViewModel class WalletViewModel @Inject constructor( @@ -44,20 +51,33 @@ class WalletViewModel @Inject constructor( private val settingsStore: SettingsStore, private val backupRepo: BackupRepo, private val blocktankRepo: BlocktankRepo, + private val migrationService: MigrationService, ) : ViewModel() { + companion object { + private const val TAG = "WalletViewModel" + private val RESTORE_WAIT_TIMEOUT = 30.seconds + } val lightningState = lightningRepo.lightningState val walletState = walletRepo.walletState val balanceState = walletRepo.balanceState + @Volatile + private var isStarting = false + // Local UI state var walletExists by mutableStateOf(walletRepo.walletExists()) private set val isRecoveryMode = lightningRepo.isRecoveryMode - var restoreState by mutableStateOf(RestoreState.Initial) - private set + val isShowingMigrationLoading: StateFlow = migrationService.isShowingMigrationLoading + + val isRestoringFromRNRemoteBackup: StateFlow = + migrationService.isRestoringFromRNRemoteBackup + + private val _restoreState = MutableStateFlow(RestoreState.Initial) + val restoreState: StateFlow = _restoreState.asStateFlow() private val _uiState = MutableStateFlow(MainUiState()) @@ -67,10 +87,62 @@ class WalletViewModel @Inject constructor( private var syncJob: Job? = null init { + checkAndPerformRNMigration() + collectStates() + } + + @Suppress("TooGenericExceptionCaught") + private fun checkAndPerformRNMigration() { + viewModelScope.launch(bgDispatcher) { + val isChecked = migrationService.isMigrationChecked() + if (isChecked) { + loadCacheIfWalletExists() + return@launch + } + + val hasNative = migrationService.hasNativeWalletData() + if (hasNative) { + migrationService.markMigrationChecked() + loadCacheIfWalletExists() + return@launch + } + + val hasRN = migrationService.hasRNWalletData() + if (!hasRN) { + migrationService.markMigrationChecked() + loadCacheIfWalletExists() + return@launch + } + + migrationService.setShowingMigrationLoading(true) + + try { + migrationService.migrateFromReactNative() + walletRepo.setWalletExistsState() + walletExists = walletRepo.walletExists() + loadCacheIfWalletExists() + if (walletExists) { + startNode(0, channelMigration = null) + } else { + migrationService.setShowingMigrationLoading(false) + } + } catch (e: Exception) { + Logger.error("RN migration failed: $e", e, context = "WalletViewModel") + migrationService.markMigrationChecked() + migrationService.setShowingMigrationLoading(false) + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = "Migration Failed", + description = "Please restore your wallet manually using your recovery phrase" + ) + } + } + } + + private fun loadCacheIfWalletExists() { if (walletExists) { walletRepo.loadFromCache() } - collectStates() } private fun collectStates() { @@ -87,7 +159,7 @@ class WalletViewModel @Inject constructor( selectedTags = state.selectedTags, ) } - if (state.walletExists && restoreState == RestoreState.InProgress.Wallet) { + if (state.walletExists && _restoreState.value == RestoreState.InProgress.Wallet) { restoreFromBackup() } } @@ -109,14 +181,48 @@ class WalletViewModel @Inject constructor( } private suspend fun restoreFromBackup() { - restoreState = RestoreState.InProgress.Metadata - 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.Completed + _restoreState.update { RestoreState.InProgress.Metadata } + try { + restoreFromMostRecentBackup() + } catch (e: Exception) { + Logger.error("Restore from backup failed", e, context = TAG) + } finally { + _restoreState.update { RestoreState.Completed } + } + } + + private suspend fun restoreFromMostRecentBackup() { + val (rnTimestamp, vssTimestamp) = coroutineScope { + val rn = async { migrationService.getRNRemoteBackupTimestamp() } + val vss = async { backupRepo.getLatestBackupTime() } + rn.await() to vss.await() + } + + val shouldRestoreRN = when { + rnTimestamp == null -> false + vssTimestamp == null || vssTimestamp == 0uL -> true + else -> rnTimestamp >= vssTimestamp + } + + if (shouldRestoreRN) { + restoreFromRNRemoteBackup() + } else { + backupRepo.performFullRestoreFromLatestBackup(onCacheRestored = walletRepo::loadFromCache) + } + } + + private suspend fun restoreFromRNRemoteBackup() { + runCatching { + migrationService.restoreFromRNRemoteBackup() + walletRepo.loadFromCache() + }.onFailure { e -> + Logger.warn("RN remote backup restore failed, falling back to VSS", e, context = TAG) + backupRepo.performFullRestoreFromLatestBackup(onCacheRestored = walletRepo::loadFromCache) + } } fun onRestoreContinue() { - restoreState = RestoreState.Settled + _restoreState.update { RestoreState.Settled } } fun proceedWithoutRestore(onDone: () -> Unit) { @@ -124,7 +230,7 @@ class WalletViewModel @Inject constructor( // TODO start LDK without trying to restore backup state from VSS if possible lightningRepo.stop() delay(LOADING_MS.milliseconds) - restoreState = RestoreState.Settled + _restoreState.update { RestoreState.Settled } onDone() } } @@ -132,25 +238,54 @@ class WalletViewModel @Inject constructor( fun setInitNodeLifecycleState() = lightningRepo.setInitNodeLifecycleState() fun start(walletIndex: Int = 0) { - if (!walletExists) return + if (!walletExists || isStarting) return viewModelScope.launch(bgDispatcher) { - lightningRepo.start(walletIndex) - .onSuccess { - walletRepo.setWalletExistsState() - walletRepo.syncBalances() - // Skip refresh during restore, it will be called after completion - if (restoreState.isIdle()) { - walletRepo.refreshBip21() - } + isStarting = true + try { + waitForRestoreIfNeeded() + + val channelMigration = buildChannelMigrationIfAvailable() + startNode(walletIndex, channelMigration) + } finally { + isStarting = false + } + } + } + + private suspend fun waitForRestoreIfNeeded() { + if (!_restoreState.value.isOngoing()) return + withTimeoutOrNull(RESTORE_WAIT_TIMEOUT) { + _restoreState.first { !it.isOngoing() } + } ?: Logger.warn("Restore wait timed out, proceeding anyway", context = TAG) + } + + private fun buildChannelMigrationIfAvailable(): ChannelDataMigration? { + val migration = migrationService.peekPendingChannelMigration() ?: return null + return ChannelDataMigration( + channelManager = migration.channelManager.map { it.toUByte() }, + channelMonitors = migration.channelMonitors.map { monitor -> monitor.map { it.toUByte() } }, + ) + } + + private suspend fun startNode( + walletIndex: Int = 0, + channelMigration: ChannelDataMigration?, + ) { + lightningRepo.start(walletIndex, channelMigration = channelMigration) + .onSuccess { + walletRepo.setWalletExistsState() + walletRepo.syncBalances() + if (_restoreState.value.isIdle()) { + walletRepo.refreshBip21() } - .onFailure { error -> - Logger.error("Node startup error", error) - if (error !is RecoveryModeException) { - ToastEventBus.send(error) - } + } + .onFailure { error -> + Logger.error("Node startup error", error, context = TAG) + if (error !is RecoveryModeException) { + ToastEventBus.send(error) } - } + } } fun stop() { @@ -251,7 +386,7 @@ class WalletViewModel @Inject constructor( suspend fun restoreWallet(mnemonic: String, bip39Passphrase: String?) { setInitNodeLifecycleState() - restoreState = RestoreState.InProgress.Wallet + _restoreState.update { RestoreState.InProgress.Wallet } walletRepo.restoreWallet( mnemonic = mnemonic, diff --git a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt index e79fa9b05..4e9e7fb58 100644 --- a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt +++ b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt @@ -81,8 +81,17 @@ class LightningNodeServiceTest : BaseUnitTest() { @Before fun setUp() = runBlocking { hiltRule.inject() - whenever(lightningRepo.start(any(), anyOrNull(), any(), anyOrNull(), anyOrNull(), captor.capture())) - .thenReturn(Result.success(Unit)) + whenever( + lightningRepo.start( + any(), + anyOrNull(), + any(), + anyOrNull(), + anyOrNull(), + captor.capture(), + anyOrNull(), + ) + ).thenReturn(Result.success(Unit)) whenever(lightningRepo.stop()).thenReturn(Result.success(Unit)) // Set up CacheStore mock diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 6374d9b2c..fba21ae9c 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -83,7 +83,7 @@ class LightningRepoTest : BaseUnitTest() { private suspend fun startNodeForTesting() { sut.setInitNodeLifecycleState() whenever(lightningService.node).thenReturn(mock()) - whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(Unit) + whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(Unit) whenever(lightningService.start(anyOrNull(), any())).thenReturn(Unit) whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) val blocktank = mock() @@ -97,7 +97,7 @@ class LightningRepoTest : BaseUnitTest() { fun `start should transition through correct states`() = test { sut.setInitNodeLifecycleState() whenever(lightningService.node).thenReturn(mock()) - whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(Unit) + whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(Unit) whenever(lightningService.start(anyOrNull(), any())).thenReturn(Unit) val blocktank = mock() whenever(coreService.blocktank).thenReturn(blocktank) @@ -435,7 +435,7 @@ class LightningRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) val inOrder = inOrder(lightningService) inOrder.verify(lightningService).stop() - inOrder.verify(lightningService).setup(any(), eq(customServerUrl), anyOrNull(), anyOrNull()) + inOrder.verify(lightningService).setup(any(), eq(customServerUrl), anyOrNull(), anyOrNull(), anyOrNull()) inOrder.verify(lightningService).start(anyOrNull(), any()) assertEquals(NodeLifecycleState.Running, sut.lightningState.value.nodeLifecycleState) } @@ -588,7 +588,7 @@ class LightningRepoTest : BaseUnitTest() { fun `start should load trusted peers from blocktank info`() = test { sut.setInitNodeLifecycleState() whenever(lightningService.node).thenReturn(null) - whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(Unit) + whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(Unit) whenever(lightningService.start(anyOrNull(), any())).thenReturn(Unit) whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) @@ -623,7 +623,8 @@ class LightningRepoTest : BaseUnitTest() { peers?.size == 2 && peers.any { it.nodeId == "node1pubkey" && it.address == "node1.example.com:9735" } && peers.any { it.nodeId == "node2pubkey" && it.address == "node2.example.com:9735" } - } + }, + anyOrNull(), ) } @@ -631,7 +632,7 @@ class LightningRepoTest : BaseUnitTest() { fun `start should pass null trusted peers when blocktank returns null`() = test { sut.setInitNodeLifecycleState() whenever(lightningService.node).thenReturn(null) - whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(Unit) + whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(Unit) whenever(lightningService.start(anyOrNull(), any())).thenReturn(Unit) whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) @@ -643,6 +644,6 @@ class LightningRepoTest : BaseUnitTest() { val result = sut.start() assertTrue(result.isSuccess) - verify(lightningService).setup(any(), anyOrNull(), anyOrNull(), isNull()) + verify(lightningService).setup(any(), anyOrNull(), anyOrNull(), isNull(), anyOrNull()) } } diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index 8afe69d9a..8f55f55a1 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -2,6 +2,7 @@ package to.bitkit.ui import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Assert.assertEquals import org.junit.Before @@ -23,6 +24,7 @@ import to.bitkit.repositories.LightningState import to.bitkit.repositories.SyncSource import to.bitkit.repositories.WalletRepo import to.bitkit.repositories.WalletState +import to.bitkit.services.MigrationService import to.bitkit.test.BaseUnitTest import to.bitkit.viewmodels.RestoreState import to.bitkit.viewmodels.WalletViewModel @@ -36,6 +38,7 @@ class WalletViewModelTest : BaseUnitTest() { private val settingsStore = mock() private val backupRepo = mock() private val blocktankRepo = mock() + private val migrationService = mock() private val lightningState = MutableStateFlow(LightningState()) private val walletState = MutableStateFlow(WalletState()) @@ -43,9 +46,10 @@ class WalletViewModelTest : BaseUnitTest() { private val isRecoveryMode = MutableStateFlow(false) @Before - fun setUp() { + fun setUp() = runBlocking { whenever(walletRepo.walletState).thenReturn(walletState) whenever(lightningRepo.lightningState).thenReturn(lightningState) + whenever(migrationService.isMigrationChecked()).thenReturn(true) sut = WalletViewModel( bgDispatcher = testDispatcher, @@ -54,6 +58,7 @@ class WalletViewModelTest : BaseUnitTest() { settingsStore = settingsStore, backupRepo = backupRepo, blocktankRepo = blocktankRepo, + migrationService = migrationService, ) } @@ -164,7 +169,7 @@ class WalletViewModelTest : BaseUnitTest() { @Test fun `backup restore should not be triggered when wallet exists while not restoring`() = test { - assertEquals(RestoreState.Initial, sut.restoreState) + assertEquals(RestoreState.Initial, sut.restoreState.value) walletState.value = walletState.value.copy(walletExists = true) @@ -176,11 +181,11 @@ class WalletViewModelTest : BaseUnitTest() { whenever(backupRepo.performFullRestoreFromLatestBackup()).thenReturn(Result.success(Unit)) walletState.value = walletState.value.copy(walletExists = true) sut.restoreWallet("mnemonic", "passphrase") - assertEquals(RestoreState.InProgress.Wallet, sut.restoreState) + assertEquals(RestoreState.InProgress.Wallet, sut.restoreState.value) sut.onRestoreContinue() - assertEquals(RestoreState.Settled, sut.restoreState) + assertEquals(RestoreState.Settled, sut.restoreState.value) } @Test @@ -189,26 +194,26 @@ class WalletViewModelTest : BaseUnitTest() { whenever(backupRepo.performFullRestoreFromLatestBackup()).thenReturn(Result.failure(testError)) sut.restoreWallet("mnemonic", "passphrase") walletState.value = walletState.value.copy(walletExists = true) - assertEquals(RestoreState.Completed, sut.restoreState) + assertEquals(RestoreState.Completed, sut.restoreState.value) sut.proceedWithoutRestore(onDone = {}) advanceUntilIdle() - assertEquals(RestoreState.Settled, sut.restoreState) + assertEquals(RestoreState.Settled, sut.restoreState.value) } @Test fun `restore state should transition as expected`() = test { whenever(backupRepo.performFullRestoreFromLatestBackup()).thenReturn(Result.success(Unit)) - assertEquals(RestoreState.Initial, sut.restoreState) + assertEquals(RestoreState.Initial, sut.restoreState.value) sut.restoreWallet("mnemonic", "passphrase") - assertEquals(RestoreState.InProgress.Wallet, sut.restoreState) + assertEquals(RestoreState.InProgress.Wallet, sut.restoreState.value) walletState.value = walletState.value.copy(walletExists = true) - assertEquals(RestoreState.Completed, sut.restoreState) + assertEquals(RestoreState.Completed, sut.restoreState.value) sut.onRestoreContinue() - assertEquals(RestoreState.Settled, sut.restoreState) + assertEquals(RestoreState.Settled, sut.restoreState.value) } @Test @@ -226,7 +231,7 @@ class WalletViewModelTest : BaseUnitTest() { whenever(testWalletRepo.walletExists()).thenReturn(true) whenever(testLightningRepo.lightningState).thenReturn(lightningState) whenever(testLightningRepo.isRecoveryMode).thenReturn(isRecoveryMode) - whenever(testLightningRepo.start(any(), anyOrNull(), any(), anyOrNull(), anyOrNull(), anyOrNull())) + whenever(testLightningRepo.start(any(), anyOrNull(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) .thenReturn(Result.success(Unit)) val testSut = WalletViewModel( @@ -236,15 +241,16 @@ class WalletViewModelTest : BaseUnitTest() { settingsStore = settingsStore, backupRepo = backupRepo, blocktankRepo = blocktankRepo, + migrationService = migrationService, ) - assertEquals(RestoreState.Initial, testSut.restoreState) + assertEquals(RestoreState.Initial, testSut.restoreState.value) assertEquals(true, testSut.walletExists) testSut.start() advanceUntilIdle() - verify(testLightningRepo).start(any(), anyOrNull(), any(), anyOrNull(), anyOrNull(), anyOrNull()) + verify(testLightningRepo).start(any(), anyOrNull(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) verify(testWalletRepo).refreshBip21() } @@ -264,7 +270,7 @@ class WalletViewModelTest : BaseUnitTest() { whenever(testWalletRepo.restoreWallet(any(), anyOrNull())).thenReturn(Result.success(Unit)) whenever(testLightningRepo.lightningState).thenReturn(lightningState) whenever(testLightningRepo.isRecoveryMode).thenReturn(isRecoveryMode) - whenever(testLightningRepo.start(any(), anyOrNull(), any(), anyOrNull(), anyOrNull(), anyOrNull())) + whenever(testLightningRepo.start(any(), anyOrNull(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) .thenReturn(Result.success(Unit)) val testSut = WalletViewModel( @@ -274,11 +280,12 @@ class WalletViewModelTest : BaseUnitTest() { settingsStore = settingsStore, backupRepo = backupRepo, blocktankRepo = blocktankRepo, + migrationService = migrationService, ) // Trigger restore to put state in non-idle testSut.restoreWallet("mnemonic", null) - assertEquals(RestoreState.InProgress.Wallet, testSut.restoreState) + assertEquals(RestoreState.InProgress.Wallet, testSut.restoreState.value) testSut.start() advanceUntilIdle() diff --git a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt index d7bc27af8..1435755d7 100644 --- a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt @@ -19,6 +19,7 @@ import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.LightningRepo import to.bitkit.services.CoreService +import to.bitkit.services.MigrationService import to.bitkit.test.BaseUnitTest import kotlin.test.assertTrue @@ -35,6 +36,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { private val activityRepo = mock() private val lightningRepo = mock() private val firebaseMessaging = mock() + private val migrationService = mock() private lateinit var sut: WipeWalletUseCase @@ -59,6 +61,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { activityRepo = activityRepo, lightningRepo = lightningRepo, firebaseMessaging = firebaseMessaging, + migrationService = migrationService, ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9342bafe0..a95f687e5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -57,7 +57,7 @@ ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } -ldk-node-android = { module = "com.github.synonymdev:ldk-node", version = "v0.7.0-rc.1" } # fork | local: remove `v` +ldk-node-android = { module = "com.github.synonymdev:ldk-node", version = "v0.7.0-rc.2" } # fork | local: remove `v` lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }