From a73a527048155ed482d0d0356392fd36adb6e35e Mon Sep 17 00:00:00 2001 From: Nicolas Fillion Date: Fri, 10 Jun 2022 15:15:56 +0200 Subject: [PATCH 1/9] (android) Allow autobackup for database files --- phoenix-android/src/main/AndroidManifest.xml | 4 ++-- phoenix-android/src/main/res/xml/backup_rules.xml | 4 ++++ phoenix-legacy/src/main/AndroidManifest.xml | 3 +-- 3 files changed, 7 insertions(+), 4 deletions(-) create mode 100644 phoenix-android/src/main/res/xml/backup_rules.xml diff --git a/phoenix-android/src/main/AndroidManifest.xml b/phoenix-android/src/main/AndroidManifest.xml index e6518cdaa..cc08d15ea 100644 --- a/phoenix-android/src/main/AndroidManifest.xml +++ b/phoenix-android/src/main/AndroidManifest.xml @@ -12,8 +12,8 @@ + + + \ No newline at end of file diff --git a/phoenix-legacy/src/main/AndroidManifest.xml b/phoenix-legacy/src/main/AndroidManifest.xml index 7a3f56bfa..35bd8ea5d 100644 --- a/phoenix-legacy/src/main/AndroidManifest.xml +++ b/phoenix-legacy/src/main/AndroidManifest.xml @@ -25,8 +25,7 @@ Date: Fri, 12 Apr 2024 11:58:00 +0200 Subject: [PATCH 2/9] (android) Amend backup rules for Android 12 Android 12 has brought additional features to the backup mechanism and uses a different xml file. Also, instead of uploading the database files, we will upload an encrypted file that contains the database. This should be easier to do than a custom BackupAgent. --- phoenix-android/src/main/AndroidManifest.xml | 3 +- .../android/utils/backup/LocalBackupHelper.kt | 35 +++++++++++++++++++ .../src/main/res/xml/backup_rules.xml | 24 +++++++++++-- .../main/res/xml/backup_rules_before_12.xml | 20 +++++++++++ 4 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/backup/LocalBackupHelper.kt create mode 100644 phoenix-android/src/main/res/xml/backup_rules_before_12.xml diff --git a/phoenix-android/src/main/AndroidManifest.xml b/phoenix-android/src/main/AndroidManifest.xml index cc08d15ea..40d0b61bb 100644 --- a/phoenix-android/src/main/AndroidManifest.xml +++ b/phoenix-android/src/main/AndroidManifest.xml @@ -13,7 +13,8 @@ - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/phoenix-android/src/main/res/xml/backup_rules_before_12.xml b/phoenix-android/src/main/res/xml/backup_rules_before_12.xml new file mode 100644 index 000000000..4135c8c6f --- /dev/null +++ b/phoenix-android/src/main/res/xml/backup_rules_before_12.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + \ No newline at end of file From 0ed6b1667a0634d72b01670cf2cef10eeada2c4c Mon Sep 17 00:00:00 2001 From: Dominique Padiou <5765435+dpad85@users.noreply.github.com> Date: Mon, 15 Apr 2024 16:06:24 +0200 Subject: [PATCH 3/9] Add a payments backup mechanism When restoring a wallet, the user can now restore a backup file if one is available to recover the payments history. This is done manually, by browsing the device's file system and selecting a file. The file is then decrypted, read, and the payments database it contains are restored in the phoenix internal db directory. This step is optional and can be skipped. If there already is a payments db in the internal db directory, this step is skipped automatically when restoring a wallet (will typically happen because of the cloud backup mechanism provided by google drive). --- phoenix-android/src/main/AndroidManifest.xml | 2 +- .../fr/acinq/phoenix/android/AppView.kt | 13 +- .../phoenix/android/components/Buttons.kt | 2 + .../fr/acinq/phoenix/android/init/InitView.kt | 160 ------- .../phoenix/android/init/RestoreWalletView.kt | 390 ------------------ .../phoenix/android/initwallet/InitView.kt | 62 +++ .../android/initwallet/InitViewModel.kt | 188 +++++++++ .../create}/CreateWalletView.kt | 14 +- .../create/CreateWalletViewModel.kt | 21 + .../restore/RestorePaymentsBackupView.kt | 129 ++++++ .../initwallet/restore/RestoreWalletView.kt | 157 +++++++ .../restore/RestoreWalletViewModel.kt | 179 ++++++++ .../initwallet/restore/SeedInputView.kt | 278 +++++++++++++ .../payments/details/PaymentDetailsView.kt | 1 + .../android/security/EncryptedBackup.kt | 23 ++ .../android/services/LocalBackupWorker.kt | 83 ++++ .../phoenix/android/services/NodeService.kt | 1 + .../phoenix/android/settings/ResetWallet.kt | 26 +- .../android/startup/StartupViewModel.kt | 8 +- .../phoenix/android/utils/BiometricsHelper.kt | 2 - .../android/utils/backup/LocalBackupHelper.kt | 302 +++++++++++++- .../src/main/res/drawable/ic_cancel.xml | 19 + .../src/main/res/xml/backup_rules.xml | 19 +- .../main/res/xml/backup_rules_before_12.xml | 4 +- .../fr/acinq/phoenix/db/androidDbFactory.kt | 9 +- .../fr.acinq.phoenix.db/ChannelsDatabase.sq | 1 + .../kotlin/fr.acinq.phoenix/db/DbFactory.kt | 4 +- .../fr.acinq.phoenix/db/SqliteChannelsDb.kt | 3 +- .../managers/DatabaseManager.kt | 22 +- .../fr/acinq/phoenix/db/iosDbFactory.kt | 18 +- 30 files changed, 1517 insertions(+), 623 deletions(-) delete mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/init/InitView.kt delete mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/init/RestoreWalletView.kt create mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/InitView.kt create mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/InitViewModel.kt rename phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/{init => initwallet/create}/CreateWalletView.kt (92%) create mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/create/CreateWalletViewModel.kt create mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/RestorePaymentsBackupView.kt create mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/RestoreWalletView.kt create mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/RestoreWalletViewModel.kt create mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/SeedInputView.kt create mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/security/EncryptedBackup.kt create mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/LocalBackupWorker.kt create mode 100644 phoenix-android/src/main/res/drawable/ic_cancel.xml diff --git a/phoenix-android/src/main/AndroidManifest.xml b/phoenix-android/src/main/AndroidManifest.xml index 40d0b61bb..3ea20b054 100644 --- a/phoenix-android/src/main/AndroidManifest.xml +++ b/phoenix-android/src/main/AndroidManifest.xml @@ -17,7 +17,7 @@ android:dataExtractionRules="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" - tools:replace="android:label" + tools:replace="android:label,android:allowBackup" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:hardwareAccelerated="true" diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt index 77838005b..ee13a1a7e 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt @@ -65,9 +65,9 @@ import fr.acinq.phoenix.android.components.Dialog import fr.acinq.phoenix.android.components.openLink import fr.acinq.phoenix.android.components.screenlock.LockPrompt import fr.acinq.phoenix.android.home.HomeView -import fr.acinq.phoenix.android.init.CreateWalletView -import fr.acinq.phoenix.android.init.InitWallet -import fr.acinq.phoenix.android.init.RestoreWalletView +import fr.acinq.phoenix.android.initwallet.create.CreateWalletView +import fr.acinq.phoenix.android.initwallet.InitWallet +import fr.acinq.phoenix.android.initwallet.restore.RestoreWalletView import fr.acinq.phoenix.android.intro.IntroView import fr.acinq.phoenix.android.payments.ScanDataView import fr.acinq.phoenix.android.payments.details.PaymentDetailsView @@ -96,6 +96,8 @@ import fr.acinq.phoenix.android.settings.channels.ImportChannelsData import fr.acinq.phoenix.android.settings.displayseed.DisplaySeedView import fr.acinq.phoenix.android.settings.fees.AdvancedIncomingFeePolicy import fr.acinq.phoenix.android.settings.fees.LiquidityPolicyView +import fr.acinq.phoenix.android.payments.liquidity.RequestLiquidityView +import fr.acinq.phoenix.android.services.LocalBackupWorker import fr.acinq.phoenix.android.settings.walletinfo.FinalWalletInfo import fr.acinq.phoenix.android.settings.walletinfo.SendSwapInRefundView import fr.acinq.phoenix.android.settings.walletinfo.SwapInAddresses @@ -241,10 +243,10 @@ fun AppView( ) } composable(Screen.CreateWallet.route) { - CreateWalletView(onSeedWritten = { navController.navigate(Screen.Startup.route) }) + CreateWalletView(onWalletCreated = { navController.navigate(Screen.Startup.route) }) } composable(Screen.RestoreWallet.route) { - RestoreWalletView(onSeedWritten = { navController.navigate(Screen.Startup.route) }) + RestoreWalletView(onRestoreDone = { navController.navigate(Screen.Startup.route) }) } composable(Screen.Home.route) { RequireStarted(walletState) { @@ -515,6 +517,7 @@ fun AppView( val userPrefs = userPrefs val exchangeRates = fiatRates lastCompletedPayment?.let { payment -> + LocalBackupWorker.scheduleOnce(context) LaunchedEffect(key1 = payment.walletPaymentId()) { if (isDataMigrationExpected == false) { if (payment is IncomingPayment && payment.origin is IncomingPayment.Origin.Offer) { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Buttons.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Buttons.kt index e5ad26176..b7baf22c8 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Buttons.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Buttons.kt @@ -107,6 +107,7 @@ fun FilledButton( textStyle: TextStyle = MaterialTheme.typography.button.copy(color = MaterialTheme.colors.onPrimary), padding: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 12.dp), backgroundColor: Color = MaterialTheme.colors.primary, + horizontalArrangement: Arrangement.Horizontal = Arrangement.Center, onClick: () -> Unit, ) { Button( @@ -120,6 +121,7 @@ fun FilledButton( onClick = onClick, shape = shape, backgroundColor = backgroundColor, + horizontalArrangement = horizontalArrangement, textStyle = textStyle, padding = padding, modifier = modifier diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/init/InitView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/init/InitView.kt deleted file mode 100644 index d303c0212..000000000 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/init/InitView.kt +++ /dev/null @@ -1,160 +0,0 @@ -/* - * Copyright 2020 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.android.init - -import android.content.Context -import androidx.compose.foundation.layout.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import fr.acinq.phoenix.android.R -import fr.acinq.phoenix.android.components.* -import fr.acinq.phoenix.android.components.mvi.MVIControllerViewModel -import fr.acinq.phoenix.android.security.EncryptedSeed -import fr.acinq.phoenix.android.security.SeedManager -import fr.acinq.phoenix.controllers.ControllerFactory -import fr.acinq.phoenix.controllers.InitializationController -import fr.acinq.phoenix.controllers.init.Initialization -import fr.acinq.phoenix.legacy.utils.LegacyAppStatus -import fr.acinq.phoenix.legacy.utils.LegacyPrefsDatastore -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - - -@Composable -fun InitWallet( - onCreateWalletClick: () -> Unit, - onRestoreWalletClick: () -> Unit, -) { - Column( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - FilledButton( - text = stringResource(id = R.string.initwallet_create), - icon = R.drawable.ic_fire, - onClick = onCreateWalletClick - ) - Spacer(modifier = Modifier.height(16.dp)) - HSeparator(width = 80.dp) - Spacer(modifier = Modifier.height(16.dp)) - BorderButton( - text = stringResource(id = R.string.initwallet_restore), - icon = R.drawable.ic_restore, - onClick = onRestoreWalletClick - ) - } -} - - -sealed class WritingSeedState { - object Init : WritingSeedState() - data class Writing(val mnemonics: List) : WritingSeedState() - data class WrittenToDisk(val encryptedSeed: EncryptedSeed) : WritingSeedState() - data class Error(val e: Throwable) : WritingSeedState() -} - -class InitViewModel(controller: InitializationController) : MVIControllerViewModel(controller) { - - /** State of the view */ - var writingState by mutableStateOf(WritingSeedState.Init) - private set - - /** State of the view */ - var restoreWalletState by mutableStateOf(RestoreWalletViewState.Disclaimer) - - var mnemonics by mutableStateOf(arrayOfNulls(12)) - private set - - fun appendWordToMnemonic(word: String) { - val index = mnemonics.indexOfFirst { it == null } - if (index in 0..11) { - mnemonics = mnemonics.copyOf().also { it[index] = word } - } - } - - fun removeWordsFromMnemonic(from: Int) { - if (from in 0..11) { - mnemonics = mnemonics.copyOf().also { it.fill(null, from) } - } - } - - /** - * Attempts to write a seed on disk and updates the view model state. If a seed already - * exists on disk, this method will not fail but it will not overwrite the existing file. - * - * @param isNewWallet when false, we will need to start the legacy app because this seed - * may be attached to a legacy wallet. - */ - suspend fun writeSeed( - context: Context, - mnemonics: List, - isNewWallet: Boolean, - onSeedWritten: () -> Unit - ) = viewModelScope.launch(Dispatchers.IO) { - if (writingState == WritingSeedState.Init) { - log.debug("writing mnemonics to disk...") - try { - writingState = WritingSeedState.Writing(mnemonics) - val existing = SeedManager.loadSeedFromDisk(context) - if (existing == null) { - val encrypted = EncryptedSeed.V2.NoAuth.encrypt(EncryptedSeed.fromMnemonics(mnemonics)) - SeedManager.writeSeedToDisk(context, encrypted) - writingState = WritingSeedState.WrittenToDisk(encrypted) - LegacyPrefsDatastore.saveStartLegacyApp(context, if (isNewWallet) LegacyAppStatus.NotRequired else LegacyAppStatus.Unknown) - if (isNewWallet) { - log.info("new seed successfully created and written to disk") - } else { - log.info("wallet successfully restored from mnemonics and written to disk") - } - } else { - log.warn("cannot overwrite existing seed=${existing.name()}") - writingState = WritingSeedState.WrittenToDisk(existing) - } - viewModelScope.launch(Dispatchers.Main) { - delay(1000) - onSeedWritten() - } - } catch (e: Exception) { - log.error("failed to write mnemonics to disk: ", e) - writingState = WritingSeedState.Error(e) - } - } - } - - class Factory( - private val controllerFactory: ControllerFactory, - private val getController: ControllerFactory.() -> InitializationController - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - @Suppress("UNCHECKED_CAST") - return InitViewModel(controllerFactory.getController()) as T - } - } -} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/init/RestoreWalletView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/init/RestoreWalletView.kt deleted file mode 100644 index e22e30cc0..000000000 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/init/RestoreWalletView.kt +++ /dev/null @@ -1,390 +0,0 @@ -/* - * Copyright 2022 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.android.init - -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Text -import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.FirstBaseline -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.lifecycle.viewmodel.compose.viewModel -import fr.acinq.bitcoin.MnemonicCode -import fr.acinq.phoenix.android.CF -import fr.acinq.phoenix.android.R -import fr.acinq.phoenix.android.components.* -import fr.acinq.phoenix.android.components.feedback.ErrorMessage -import fr.acinq.phoenix.android.components.feedback.SuccessMessage -import fr.acinq.phoenix.android.components.feedback.WarningMessage -import fr.acinq.phoenix.android.components.mvi.MVIView -import fr.acinq.phoenix.android.controllerFactory -import fr.acinq.phoenix.android.navController -import fr.acinq.phoenix.android.security.SeedFileState -import fr.acinq.phoenix.android.security.SeedManager -import fr.acinq.phoenix.android.utils.negativeColor -import fr.acinq.phoenix.controllers.init.RestoreWallet -import fr.acinq.phoenix.utils.MnemonicLanguage - -sealed class RestoreWalletViewState { - object Disclaimer : RestoreWalletViewState() - object Restore : RestoreWalletViewState() -} - -@Composable -fun RestoreWalletView( - onSeedWritten: () -> Unit -) { - val nc = navController - val context = LocalContext.current - val vm: InitViewModel = viewModel(factory = InitViewModel.Factory(controllerFactory, CF::initialization)) - - val seedFileState = produceState(initialValue = SeedFileState.Unknown, true) { - value = SeedManager.getSeedState(context) - } - - DefaultScreenLayout { - DefaultScreenHeader( - onBackClick = { nc.popBackStack() }, - title = stringResource(id = R.string.restore_title), - ) - when (seedFileState.value) { - is SeedFileState.Absent -> { - when (val state = vm.restoreWalletState) { - is RestoreWalletViewState.Disclaimer -> { - DisclaimerView(onClickNext = { vm.restoreWalletState = RestoreWalletViewState.Restore }) - } - is RestoreWalletViewState.Restore -> { - SeedInputView(vm = vm, onSeedWritten = onSeedWritten) - } - } - } - SeedFileState.Unknown -> { - Text(stringResource(id = R.string.startup_wait)) - } - else -> { - // we should not be here - Text(stringResource(id = R.string.startup_wait)) - LaunchedEffect(true) { - onSeedWritten() - } - } - } - } -} - -@Composable -private fun DisclaimerView( - onClickNext: () -> Unit -) { - var hasCheckedWarning by rememberSaveable { mutableStateOf(false) } - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Card(internalPadding = PaddingValues(16.dp)) { - Text(stringResource(R.string.restore_disclaimer_message)) - } - Checkbox( - text = stringResource(R.string.utils_ack), - checked = hasCheckedWarning, - onCheckedChange = { hasCheckedWarning = it }, - ) - BorderButton( - text = stringResource(id = R.string.restore_disclaimer_next), - icon = R.drawable.ic_arrow_next, - onClick = (onClickNext), - enabled = hasCheckedWarning, - ) - } -} - -@Composable -private fun SeedInputView( - vm: InitViewModel, - onSeedWritten: () -> Unit -) { - val context = LocalContext.current - val focusManager = LocalFocusManager.current - var filteredWords by remember { mutableStateOf(emptyList()) } - val writingState = vm.writingState - val enteredWords = vm.mnemonics.filterNot { it.isNullOrBlank() } - - MVIView(CF::restoreWallet) { model, postIntent -> - - val isSeedValid = remember(enteredWords.size, model) { - if (model is RestoreWallet.Model.InvalidMnemonics) { - false - } else if (enteredWords.size != 12) { - null - } else { - try { - MnemonicCode.validate(vm.mnemonics.joinToString(" ")) - true - } catch (e: Exception) { - false - } - } - } - - when (model) { - is RestoreWallet.Model.Ready -> {} - is RestoreWallet.Model.InvalidMnemonics -> {} - is RestoreWallet.Model.FilteredWordlist -> { - filteredWords = model.words - } - is RestoreWallet.Model.ValidMnemonics -> { - LaunchedEffect(model.seed) { - vm.writeSeed( - context = context, - mnemonics = model.mnemonics, - isNewWallet = false, - onSeedWritten = onSeedWritten - ) - } - } - } - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Card( - internalPadding = PaddingValues(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text(text = stringResource(R.string.restore_instructions)) - Column( - modifier = Modifier.heightIn(min = 100.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - when (isSeedValid) { - null -> { - Spacer(modifier = Modifier.height(8.dp)) - WordInputView( - wordIndex = enteredWords.size + 1, - filteredWords = filteredWords, - onInputChange = { postIntent(RestoreWallet.Intent.FilterWordList(it, MnemonicLanguage.English)) }, - onWordSelected = { vm.appendWordToMnemonic(it) }, - ) - } - false -> { - WarningMessage( - header = stringResource(id = R.string.restore_seed_invalid), - details = stringResource(id = R.string.restore_seed_invalid_details), - alignment = Alignment.CenterHorizontally, - ) - } - true -> { - SuccessMessage( - header = stringResource(id = R.string.restore_seed_valid), - details = stringResource(id = R.string.restore_seed_valid_details), - alignment = Alignment.CenterHorizontally, - ) - } - } - } - Spacer(modifier = Modifier.height(8.dp)) - WordsTable( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.CenterHorizontally) - .padding(horizontal = 16.dp) - .widthIn(max = 350.dp), - words = vm.mnemonics.toList(), - onRemoveWordFrom = { - if (writingState !is WritingSeedState.Writing) { - vm.removeWordsFromMnemonic(it) - } - } - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - when (writingState) { - is WritingSeedState.Error -> { - ErrorMessage( - header = stringResource(id = R.string.autocreate_error), - details = writingState.e.localizedMessage ?: writingState.e::class.java.simpleName, - alignment = Alignment.CenterHorizontally, - ) - } - is WritingSeedState.Init -> { - BorderButton( - text = stringResource(id = R.string.restore_import_button), - icon = R.drawable.ic_check_circle, - onClick = { - focusManager.clearFocus() - postIntent(RestoreWallet.Intent.Validate(vm.mnemonics.filterNotNull(), MnemonicLanguage.English)) - }, - enabled = isSeedValid == true, - ) - } - is WritingSeedState.Writing, is WritingSeedState.WrittenToDisk -> { - ProgressView(text = stringResource(R.string.restore_in_progress)) - } - } - Spacer(modifier = Modifier.height(48.dp)) - } - } -} - -@Composable -private fun WordInputView( - wordIndex: Int, - filteredWords: List, - onInputChange: (String) -> Unit, - onWordSelected: (String) -> Unit, -) { - var inputValue by remember { mutableStateOf("") } - OutlinedTextField( - value = inputValue, - onValueChange = { newValue -> - if (newValue.endsWith(" ") && filteredWords.isNotEmpty()) { - // hitting space acts like completing the input - we select the first word available - filteredWords.firstOrNull()?.let { onWordSelected(it) } - inputValue = "" - } else { - inputValue = newValue - } - onInputChange(inputValue.trim()) - }, - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrect = false, - keyboardType = KeyboardType.Password, - imeAction = ImeAction.None - ), - visualTransformation = VisualTransformation.None, - label = { - Text( - text = stringResource(R.string.restore_input_label, wordIndex), - style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.primary) - ) - }, - maxLines = 1, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - - // Section showing the list of filtered words. Clicking on a word acts like completing the text input. - if (filteredWords.isEmpty()) { - Text( - text = if (inputValue.length > 2) stringResource(id = R.string.restore_input_invalid) else "", - style = MaterialTheme.typography.body1.copy(color = negativeColor), - modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp) - ) - } else { - LazyRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - items(filteredWords) { - Clickable( - enabled = wordIndex <= 12, - onClick = { - onWordSelected(it) - onInputChange("") - inputValue = "" - }, - ) { - Text(text = it, style = MaterialTheme.typography.body1.copy(textDecoration = TextDecoration.Underline), modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)) - } - } - } - } -} - -@Composable -private fun WordsTable( - modifier: Modifier, - words: List, - onRemoveWordFrom: (Int) -> Unit, -) { - Row(modifier) { - Column(modifier = Modifier.weight(1f)) { - words.take(6).forEachIndexed { index, word -> - WordRow( - wordNumber = index + 1, - word = word, - onRemoveWordClick = { onRemoveWordFrom(index) } - ) - } - } - Spacer(Modifier.width(8.dp)) - Column(Modifier.weight(1f)) { - words.subList(6, 12).forEachIndexed { index, word -> - WordRow( - wordNumber = index + 6 + 1, - word = word, - onRemoveWordClick = { onRemoveWordFrom(index + 6) } - ) - } - } - } -} - -@Composable -private fun WordRow( - wordNumber: Int, - word: String?, - onRemoveWordClick: () -> Unit -) { - Clickable( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)), - enabled = !word.isNullOrBlank(), - onClick = onRemoveWordClick, - internalPadding = PaddingValues(4.dp) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = String.format("#%02d -", wordNumber), - style = MaterialTheme.typography.caption.copy(fontSize = 12.sp), - modifier = Modifier.alignBy(FirstBaseline) - ) - Spacer(Modifier.width(4.dp)) - Text( - text = word ?: "...", - style = if (word != null) MaterialTheme.typography.body2 else MaterialTheme.typography.caption, - modifier = Modifier.alignBy(FirstBaseline) - ) - if (!word.isNullOrBlank()) { - Spacer(Modifier.weight(1f)) - PhoenixIcon(resourceId = R.drawable.ic_cross, tint = MaterialTheme.colors.primary) - } - } - } -} diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/InitView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/InitView.kt new file mode 100644 index 000000000..4ac56e8a9 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/InitView.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2020 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.initwallet + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.components.BorderButton +import fr.acinq.phoenix.android.components.FilledButton +import fr.acinq.phoenix.android.components.HSeparator + + +@Composable +fun InitWallet( + onCreateWalletClick: () -> Unit, + onRestoreWalletClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + FilledButton( + text = stringResource(id = R.string.initwallet_create), + icon = R.drawable.ic_fire, + onClick = onCreateWalletClick + ) + Spacer(modifier = Modifier.height(16.dp)) + HSeparator(width = 80.dp) + Spacer(modifier = Modifier.height(16.dp)) + BorderButton( + text = stringResource(id = R.string.initwallet_restore), + icon = R.drawable.ic_restore, + onClick = onRestoreWalletClick + ) + } +} diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/InitViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/InitViewModel.kt new file mode 100644 index 000000000..78a546a43 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/InitViewModel.kt @@ -0,0 +1,188 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.initwallet + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import fr.acinq.bitcoin.MnemonicCode +import fr.acinq.phoenix.android.security.EncryptedSeed +import fr.acinq.phoenix.android.security.SeedManager +import fr.acinq.phoenix.legacy.utils.LegacyAppStatus +import fr.acinq.phoenix.legacy.utils.LegacyPrefsDatastore +import fr.acinq.phoenix.utils.MnemonicLanguage +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory + +sealed class WritingSeedState { + data object Init : WritingSeedState() + data class Writing(val mnemonics: List) : WritingSeedState() + data class WrittenToDisk(val encryptedSeed: EncryptedSeed) : WritingSeedState() + data class Error(val e: Throwable) : WritingSeedState() +} + +abstract class InitWalletViewModel: ViewModel() { + + val log = LoggerFactory.getLogger(this::class.java) + + /** State to monitor the writing of a seed to the disk, used by the restore view and the create view thru [writeSeed]. */ + var writingState by mutableStateOf(WritingSeedState.Init) + private set + + /** + * Attempts to write a seed on disk and updates the view model state. If a seed already + * exists on disk, this method will not fail but it will not overwrite the existing file. + * + * @param isNewWallet when false, we will need to start the legacy app because this seed + * may be attached to a legacy wallet. + */ + fun writeSeed( + context: Context, + mnemonics: List, + isNewWallet: Boolean, + onSeedWritten: () -> Unit + ) { + if (writingState !is WritingSeedState.Init) return + viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e -> + log.error("failed to write seed to disk: ", e) + writingState = WritingSeedState.Error(e) + }) { + writingState = WritingSeedState.Writing(mnemonics) + log.debug("writing mnemonics to disk...") + MnemonicCode.validate(mnemonics, MnemonicLanguage.English.wordlist()) + val existing = SeedManager.loadSeedFromDisk(context) + if (existing == null) { + val encrypted = EncryptedSeed.V2.NoAuth.encrypt(EncryptedSeed.fromMnemonics(mnemonics)) + SeedManager.writeSeedToDisk(context, encrypted) + writingState = WritingSeedState.WrittenToDisk(encrypted) + LegacyPrefsDatastore.saveStartLegacyApp(context, if (isNewWallet) LegacyAppStatus.NotRequired else LegacyAppStatus.Unknown) + if (isNewWallet) { + log.info("new seed successfully created and written to disk") + } else { + log.info("wallet successfully restored from mnemonics and written to disk") + } + } else { + log.warn("cannot overwrite existing seed=${existing.name()}") + writingState = WritingSeedState.WrittenToDisk(existing) + } + viewModelScope.launch(Dispatchers.Main) { + delay(1000) + onSeedWritten() + } + } + } +} + + +// /** State of the restore wallet view */ +// var restoreWalletState by mutableStateOf(RestoreWalletViewState.Disclaimer) +// +// var checkBackupState by mutableStateOf(CheckBackupState.Init) +// + + + + + + + +// fun attemptBackupRestore(context: Context, seed: ByteArray, onRestoreDone: () -> Unit) { +// if (checkBackupState is CheckBackupState.Checking) return +// log.info("checking for backup files") +// +//// fun onRestoreDoneMain() { +//// viewModelScope.launch(Dispatchers.Main) { +//// onRestoreDone() +//// } +//// } +// +// checkBackupState = CheckBackupState.Checking.LookingForFile +// viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e -> +// log.error("error when checking backup: ", e) +// checkBackupState = CheckBackupState.Done.Failed.Error(e) +// // onRestoreDoneMain() +// }) { +// if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { +// log.info("unsupported android version=${Build.VERSION.SDK_INT}") +// checkBackupState = CheckBackupState.Done.Failed.UnsupportedAndroidVersion +// //onRestoreDoneMain() +// return@launch +// } +// val keyManager = LocalKeyManager(seed = seed.byteVector(), chain = NodeParamsManager.chain, remoteSwapInExtendedPublicKey = NodeParamsManager.remoteSwapInXpub) +// when (val encryptedBackup = LocalBackupHelper.getBackupData(context, keyManager)) { +// null -> { +// log.info("no backup found") +// checkBackupState = CheckBackupState.Done.NoBackupFound +// //onRestoreDoneMain() +// return@launch +// } +// else -> { +// log.info("found backup, decrypting file...") +// checkBackupState = CheckBackupState.Checking.Decrypting +// val data = try { +// encryptedBackup.decrypt(keyManager) +// } catch (e: Exception) { +// log.error("cannot decrypt backup file: ", e) +// checkBackupState = CheckBackupState.Done.Failed.CannotDecrypt(encryptedBackup) +// //onRestoreDoneMain() +// return@launch +// } +// +// log.info("unzipping backup...") +// val files = LocalBackupHelper.unzipData(data) +// val nodeId = keyManager.nodeKeys.nodeKey.publicKey +// val channelsDbEntry = files.filterKeys { it == DatabaseManager.channelsDbName(NodeParamsManager.chain, nodeId) }.entries.firstOrNull() +// val paymentsDbEntry = files.filterKeys { it == DatabaseManager.paymentsDbName(NodeParamsManager.chain, nodeId) }.entries.firstOrNull() +// +// if (channelsDbEntry == null || paymentsDbEntry == null) { +// log.error("missing channels or payments database file in zip backup") +// checkBackupState = CheckBackupState.Done.Failed.InvalidName +// //onRestoreDoneMain() +// return@launch +// } else { +// log.info("restoring channels and payments database files") +// checkBackupState = try { +// // LocalBackupHelper.restoreFilesToPrivateDir(context, channelsDbEntry.key, channelsDbEntry.value, paymentsDbEntry.key, paymentsDbEntry.value) +// log.info("backup files have been restored") +// CheckBackupState.Done.BackupRestored +// } catch (e: Exception) { +// log.error("channels or payments files already exist in database directory") +// CheckBackupState.Done.Failed.AlreadyExist +// } +// // onRestoreDoneMain() +// } +// } +// } +// } +// } +// +// class Factory( +// private val controllerFactory: ControllerFactory, +// private val getController: ControllerFactory.() -> InitializationController +// ) : ViewModelProvider.Factory { +// override fun create(modelClass: Class): T { +// @Suppress("UNCHECKED_CAST") +// return InitViewModel(controllerFactory.getController()) as T +// } +// } +//} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/init/CreateWalletView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/create/CreateWalletView.kt similarity index 92% rename from phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/init/CreateWalletView.kt rename to phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/create/CreateWalletView.kt index 06edfac2f..46f42a1fe 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/init/CreateWalletView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/create/CreateWalletView.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package fr.acinq.phoenix.android.init +package fr.acinq.phoenix.android.initwallet.create import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -35,7 +35,7 @@ import fr.acinq.phoenix.android.CF import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.components.feedback.ErrorMessage import fr.acinq.phoenix.android.components.mvi.MVIView -import fr.acinq.phoenix.android.controllerFactory +import fr.acinq.phoenix.android.initwallet.WritingSeedState import fr.acinq.phoenix.android.security.SeedFileState import fr.acinq.phoenix.android.security.SeedManager import fr.acinq.phoenix.android.utils.logger @@ -46,12 +46,12 @@ import fr.acinq.phoenix.utils.MnemonicLanguage @Composable fun CreateWalletView( - onSeedWritten: () -> Unit + onWalletCreated: () -> Unit ) { val log = logger("CreateWallet") val context = LocalContext.current - val vm: InitViewModel = viewModel(factory = InitViewModel.Factory(controllerFactory, CF::initialization)) + val vm = viewModel() val seedFileState = produceState(initialValue = SeedFileState.Unknown, true) { value = SeedManager.getSeedState(context) @@ -64,7 +64,7 @@ fun CreateWalletView( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - when (seedFileState.value) { + when (val state = seedFileState.value) { is SeedFileState.Absent -> { Text(stringResource(id = R.string.autocreate_generating)) MVIView(CF::initialization) { model, postIntent -> @@ -86,7 +86,7 @@ fun CreateWalletView( ) } LaunchedEffect(true) { - vm.writeSeed(context, model.mnemonics, isNewWallet = true, onSeedWritten) + vm.writeSeed(context, model.mnemonics, isNewWallet = true, onWalletCreated) LegacyPrefsDatastore.savePrefsMigrationExpected(context, false) LegacyPrefsDatastore.saveDataMigrationExpected(context, false) } @@ -101,7 +101,7 @@ fun CreateWalletView( // we should not be here Text(stringResource(id = R.string.startup_wait)) LaunchedEffect(true) { - onSeedWritten() + onWalletCreated() } } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/create/CreateWalletViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/create/CreateWalletViewModel.kt new file mode 100644 index 000000000..6e4c34777 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/create/CreateWalletViewModel.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.initwallet.create + +import fr.acinq.phoenix.android.initwallet.InitWalletViewModel + +class CreateWalletViewModel : InitWalletViewModel() \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/RestorePaymentsBackupView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/RestorePaymentsBackupView.kt new file mode 100644 index 000000000..33c4803c6 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/RestorePaymentsBackupView.kt @@ -0,0 +1,129 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.initwallet.restore + +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.components.Button +import fr.acinq.phoenix.android.components.Card +import fr.acinq.phoenix.android.components.FilledButton +import fr.acinq.phoenix.android.components.ProgressView +import fr.acinq.phoenix.android.components.feedback.ErrorMessage +import fr.acinq.phoenix.android.components.feedback.SuccessMessage + +@Composable +fun RestorePaymentsBackupView( + words: List, + vm: RestoreWalletViewModel, + onBackupRestoreDone: () -> Unit, +) { + val context = LocalContext.current + + BackHandler { /* Disable back button */ } + + val filePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + onResult = { uri -> + if (uri != null) { + vm.restorePaymentsBackup(context, words = words, uri = uri, onBackupRestoreDone = onBackupRestoreDone) + } else { + vm.restoreBackupState = RestoreBackupState.Done.Failure.UnresolvedContent + } + } + ) + + Card( + internalPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text(text = "You can manually restore your payments history by importing a Phoenix backup file, if one exists.") + Text(text = "Look for a phoenix.bak file in your Documents folder. Note that older versions of Phoenix (before v2.3.0) did not generate backups.") + } + + Spacer(modifier = Modifier.height(16.dp)) + + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val state = vm.restoreBackupState + when (state) { + is RestoreBackupState.Init, is RestoreBackupState.Done.Failure -> { + if (state is RestoreBackupState.Done.Failure) { + ErrorMessage( + modifier = Modifier.fillMaxWidth(), + header = "This file cannot be restored", + details = when (state) { + is RestoreBackupState.Done.Failure.UnresolvedContent -> "The file cannot be opened, or is empty." + is RestoreBackupState.Done.Failure.CannotWriteFiles -> "Cannot load this backup into Phoenix." + is RestoreBackupState.Done.Failure.ContentDoesNotMatch -> "This backup does not match the wallet being restored." + is RestoreBackupState.Done.Failure.CannotDecrypt -> "This file cannot be decrypted. It does not match the wallet being restored." + is RestoreBackupState.Done.Failure.Error -> state.e.message + }, + alignment = Alignment.CenterHorizontally + ) + Spacer(modifier = Modifier.height(8.dp)) + } + FilledButton( + text = "Browse local files", + icon = R.drawable.ic_inspect, + onClick = { + vm.restoreBackupState = RestoreBackupState.Checking.LookingForFile + filePickerLauncher.launch(arrayOf("*/*")) + }, + modifier = Modifier.fillMaxWidth(), + padding = PaddingValues(16.dp) + ) + } + is RestoreBackupState.Checking.LookingForFile -> { + ProgressView(text = "Looking for file...") + } + is RestoreBackupState.Checking.Decrypting -> { + ProgressView(text = "Decrypting file...") + } + RestoreBackupState.Done.BackupRestored -> { + SuccessMessage(header = "Backup has been successfully restored") + } + } + + Spacer(modifier = Modifier.height(32.dp)) + Button( + text = "Skip and proceed to wallet", + icon = R.drawable.ic_cancel, + onClick = onBackupRestoreDone, + modifier = Modifier.fillMaxWidth(), + enabled = state is RestoreBackupState.Init || state is RestoreBackupState.Done.Failure, + shape = CircleShape + ) + } +} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/RestoreWalletView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/RestoreWalletView.kt new file mode 100644 index 000000000..56a391582 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/RestoreWalletView.kt @@ -0,0 +1,157 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.initwallet.restore + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.components.BorderButton +import fr.acinq.phoenix.android.components.Card +import fr.acinq.phoenix.android.components.Checkbox +import fr.acinq.phoenix.android.components.DefaultScreenHeader +import fr.acinq.phoenix.android.components.DefaultScreenLayout +import fr.acinq.phoenix.android.components.ProgressView +import fr.acinq.phoenix.android.components.feedback.ErrorMessage +import fr.acinq.phoenix.android.initwallet.WritingSeedState +import fr.acinq.phoenix.android.navController +import fr.acinq.phoenix.android.security.SeedFileState +import fr.acinq.phoenix.android.security.SeedManager + +@Composable +fun RestoreWalletView( + onRestoreDone: () -> Unit, +) { + val nc = navController + val context = LocalContext.current + val vm = viewModel() + + val seedFileState = produceState(initialValue = SeedFileState.Unknown, true) { + value = SeedManager.getSeedState(context) + } + + when (val writingState = vm.writingState) { + is WritingSeedState.Init -> { + DefaultScreenLayout { + DefaultScreenHeader( + onBackClick = { nc.popBackStack() }, + title = stringResource(id = R.string.restore_title), + ) + + when (seedFileState.value) { + is SeedFileState.Absent -> { + when (val state = vm.state) { + is RestoreWalletState.Disclaimer -> { + DisclaimerView(onClickNext = { vm.state = RestoreWalletState.InputSeed }) + } + + is RestoreWalletState.InputSeed -> { + SeedInputView( + vm = vm, + onConfirmClick = { vm.checkSeedAndLocalFiles(context, onSeedWritten = onRestoreDone) } + ) + } + + is RestoreWalletState.RestoreBackup -> { + RestorePaymentsBackupView( + words = state.words, + vm = vm, + onBackupRestoreDone = { + vm.writeSeed( + context = context, + mnemonics = vm.mnemonics.filterNotNull(), + isNewWallet = false, + onSeedWritten = onRestoreDone + ) + } + ) + } + } + } + + SeedFileState.Unknown -> { + Text(stringResource(id = R.string.startup_wait)) + } + + else -> { + // we should not be here + LaunchedEffect(true) { + onRestoreDone() + } + } + } + } + } + is WritingSeedState.Writing, is WritingSeedState.WrittenToDisk -> { + BackHandler {} + Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { + ProgressView(text = stringResource(id = R.string.restore_in_progress)) + } + } + is WritingSeedState.Error -> { + Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { + ErrorMessage( + header = stringResource(id = R.string.autocreate_error), + details = writingState.e.localizedMessage ?: writingState.e::class.java.simpleName, + alignment = Alignment.CenterHorizontally, + ) + } + } + } +} + +@Composable +private fun DisclaimerView( + onClickNext: () -> Unit +) { + var hasCheckedWarning by rememberSaveable { mutableStateOf(false) } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Card(internalPadding = PaddingValues(16.dp)) { + Text(stringResource(R.string.restore_disclaimer_message)) + } + Checkbox( + text = stringResource(R.string.restore_disclaimer_checkbox), + checked = hasCheckedWarning, + onCheckedChange = { hasCheckedWarning = it }, + ) + BorderButton( + text = stringResource(id = R.string.restore_disclaimer_next), + icon = R.drawable.ic_arrow_next, + onClick = (onClickNext), + enabled = hasCheckedWarning, + ) + } +} diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/RestoreWalletViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/RestoreWalletViewModel.kt new file mode 100644 index 000000000..4aadad378 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/RestoreWalletViewModel.kt @@ -0,0 +1,179 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.initwallet.restore + +import android.content.Context +import android.net.Uri +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.viewModelScope +import fr.acinq.bitcoin.MnemonicCode +import fr.acinq.bitcoin.byteVector +import fr.acinq.lightning.crypto.LocalKeyManager +import fr.acinq.phoenix.android.initwallet.InitWalletViewModel +import fr.acinq.phoenix.android.utils.backup.EncryptedBackup +import fr.acinq.phoenix.android.utils.backup.LocalBackupHelper +import fr.acinq.phoenix.managers.DatabaseManager +import fr.acinq.phoenix.managers.NodeParamsManager +import fr.acinq.phoenix.utils.MnemonicLanguage +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +sealed class RestoreWalletState { + data object Disclaimer : RestoreWalletState() + data object InputSeed : RestoreWalletState() + data class RestoreBackup(val words: List) : RestoreWalletState() +} + +sealed class RestoreBackupState { + data object Init : RestoreBackupState() + + sealed class Checking : RestoreBackupState() { + data object LookingForFile : Checking() + data object Decrypting : Checking() + } + + sealed class Done : RestoreBackupState() { + data object BackupRestored : Done() + + sealed class Failure : Done() { + data object UnresolvedContent : Failure() + data class Error(val e: Throwable) : Failure() + data class CannotDecrypt(val encryptedBackup: EncryptedBackup) : Failure() + data object ContentDoesNotMatch : Failure() + data object CannotWriteFiles : Failure() + } + } +} + +class RestoreWalletViewModel: InitWalletViewModel() { + + var state by mutableStateOf(RestoreWalletState.Disclaimer) + + var restoreBackupState by mutableStateOf(RestoreBackupState.Init) + + var mnemonics by mutableStateOf(arrayOfNulls(12)) + private set + + private val language = MnemonicLanguage.English + + fun filterWordsMatching(predicate: String): List { + return when { + predicate.length > 1 -> language.matches(predicate) + else -> emptyList() + } + } + + fun appendWordToMnemonic(word: String) { + val index = mnemonics.indexOfFirst { it == null } + if (index in 0..11) { + mnemonics = mnemonics.copyOf().also { it[index] = word } + } + } + + fun removeWordsFromMnemonic(from: Int) { + if (from in 0..11) { + mnemonics = mnemonics.copyOf().also { it.fill(null, from) } + } + } + + fun checkSeedAndLocalFiles(context: Context, onSeedWritten: () -> Unit) { + viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e -> + log.error("error when checking seed and db files: ${e.message}") + }) { + val words = mnemonics.filterNot { it.isNullOrBlank() }.filterNotNull() + if (words.size != 12) throw RuntimeException("invalid mnemonics size=${words.size}") + MnemonicCode.validate(words, MnemonicLanguage.English.wordlist()) + + val seed = MnemonicCode.toSeed(words, "") + val keyManager = LocalKeyManager(seed = seed.byteVector(), chain = NodeParamsManager.chain, remoteSwapInExtendedPublicKey = NodeParamsManager.remoteSwapInXpub) + val nodeId = keyManager.nodeKeys.nodeKey.publicKey + val paymentsDb = context.getDatabasePath(DatabaseManager.paymentsDbName(NodeParamsManager.chain, nodeId)) + + if (paymentsDb.exists()) { + log.info("found payments database, skipping backup step") + writeSeed(context, words, isNewWallet = false, onSeedWritten = onSeedWritten) + } else { + log.info("no database found for this wallet, moving to backup restore step") + state = RestoreWalletState.RestoreBackup(words) + } + } + } + + fun restorePaymentsBackup(context: Context, words: List, uri: Uri, onBackupRestoreDone: () -> Unit) { + if (restoreBackupState is RestoreBackupState.Checking.Decrypting || restoreBackupState is RestoreBackupState.Done.BackupRestored) return + viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e -> + log.error("error when restoring backup: ", e) + restoreBackupState = RestoreBackupState.Done.Failure.Error(e) + }) { + + MnemonicCode.validate(words, MnemonicLanguage.English.wordlist()) + val seed = MnemonicCode.toSeed(words, "") + val keyManager = LocalKeyManager(seed = seed.byteVector(), chain = NodeParamsManager.chain, remoteSwapInExtendedPublicKey = NodeParamsManager.remoteSwapInXpub) + + when (val encryptedBackup = LocalBackupHelper.resolveUriContent(context, uri)) { + null -> { + delay(500) + log.info("content could not be resolved for uri=$uri") + restoreBackupState = RestoreBackupState.Done.Failure.UnresolvedContent + return@launch + } + else -> { + log.info("found backup, decrypting file...") + restoreBackupState = RestoreBackupState.Checking.Decrypting + delay(700) + val data = try { + encryptedBackup.decrypt(keyManager) + } catch (e: Exception) { + log.error("cannot decrypt backup file: ", e) + restoreBackupState = RestoreBackupState.Done.Failure.CannotDecrypt(encryptedBackup) + return@launch + } + + log.info("unzipping backup file...") + val files = LocalBackupHelper.unzipData(data) + delay(300) + val nodeId = keyManager.nodeKeys.nodeKey.publicKey + val paymentsDbEntry = files.filterKeys { it == DatabaseManager.paymentsDbName(NodeParamsManager.chain, nodeId) }.entries.firstOrNull() + + if (paymentsDbEntry == null) { + log.error("missing payments database file in zip backup") + restoreBackupState = RestoreBackupState.Done.Failure.ContentDoesNotMatch + return@launch + } else { + log.info("restoring payments database files") + try { + LocalBackupHelper.restoreDbFile(context, paymentsDbEntry.key, paymentsDbEntry.value) + log.info("payments db has been restored") + restoreBackupState = RestoreBackupState.Done.BackupRestored + delay(1000) + viewModelScope.launch(Dispatchers.Main) { + onBackupRestoreDone() + } + } catch (e: Exception) { + log.error("cannot write backup files to database directory: ", e) + restoreBackupState = RestoreBackupState.Done.Failure.CannotWriteFiles + } + } + } + } + } + } +} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/SeedInputView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/SeedInputView.kt new file mode 100644 index 000000000..9128a08aa --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/SeedInputView.kt @@ -0,0 +1,278 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.initwallet.restore + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.FirstBaseline +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import fr.acinq.bitcoin.MnemonicCode +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.components.BorderButton +import fr.acinq.phoenix.android.components.Card +import fr.acinq.phoenix.android.components.Clickable +import fr.acinq.phoenix.android.components.PhoenixIcon +import fr.acinq.phoenix.android.components.feedback.SuccessMessage +import fr.acinq.phoenix.android.components.feedback.WarningMessage +import fr.acinq.phoenix.android.utils.negativeColor + +@Composable +fun ColumnScope.SeedInputView( + vm: RestoreWalletViewModel, + onConfirmClick: () -> Unit +) { + val focusManager = LocalFocusManager.current + var filteredWords by remember { mutableStateOf(emptyList()) } + val enteredWords = vm.mnemonics.filterNot { it.isNullOrBlank() } + + val isSeedValid = remember(enteredWords) { + if (enteredWords.size != 12) { + null + } else { + try { + MnemonicCode.validate(vm.mnemonics.joinToString(" ")) + true + } catch (e: Exception) { + false + } + } + } + + Card( + internalPadding = PaddingValues(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = stringResource(R.string.restore_instructions)) + Column( + modifier = Modifier.heightIn(min = 100.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + when (isSeedValid) { + null -> { + Spacer(modifier = Modifier.height(8.dp)) + WordInputView( + wordIndex = enteredWords.size + 1, + filteredWords = filteredWords, + onInputChange = { filteredWords = vm.filterWordsMatching(it) }, + onWordSelected = { vm.appendWordToMnemonic(it) }, + ) + } + false -> { + WarningMessage( + header = stringResource(id = R.string.restore_seed_invalid), + details = stringResource(id = R.string.restore_seed_invalid_details), + alignment = Alignment.CenterHorizontally, + ) + } + true -> { + SuccessMessage( + header = stringResource(id = R.string.restore_seed_valid), + details = stringResource(id = R.string.restore_seed_valid_details), + alignment = Alignment.CenterHorizontally, + ) + } + } + } + Spacer(modifier = Modifier.height(8.dp)) + WordsTable( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.CenterHorizontally) + .padding(horizontal = 16.dp) + .widthIn(max = 350.dp), + words = vm.mnemonics.toList(), + onRemoveWordFrom = { vm.removeWordsFromMnemonic(it) } + ) + } + + + Spacer(modifier = Modifier.height(16.dp)) + + BorderButton( + text = stringResource(id = R.string.btn_next), + icon = R.drawable.ic_arrow_next, + onClick = { + focusManager.clearFocus() + onConfirmClick() + }, + enabled = isSeedValid == true, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) +} + +@Composable +private fun WordInputView( + wordIndex: Int, + filteredWords: List, + onInputChange: (String) -> Unit, + onWordSelected: (String) -> Unit, +) { + var inputValue by remember { mutableStateOf("") } + OutlinedTextField( + value = inputValue, + onValueChange = { newValue -> + if (newValue.endsWith(" ") && filteredWords.isNotEmpty()) { + // hitting space acts like completing the input - we select the first word available + filteredWords.firstOrNull()?.let { onWordSelected(it) } + inputValue = "" + } else { + inputValue = newValue + } + onInputChange(inputValue.trim()) + }, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrect = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.None + ), + visualTransformation = VisualTransformation.None, + label = { + Text( + text = stringResource(R.string.restore_input_label, wordIndex), + style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.primary) + ) + }, + maxLines = 1, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + // Section showing the list of filtered words. Clicking on a word acts like completing the text input. + if (filteredWords.isEmpty()) { + Text( + text = if (inputValue.length > 2) stringResource(id = R.string.restore_input_invalid) else "", + style = MaterialTheme.typography.body1.copy(color = negativeColor), + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp) + ) + } else { + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(filteredWords) { + Clickable( + enabled = wordIndex <= 12, + onClick = { + onWordSelected(it) + onInputChange("") + inputValue = "" + }, + ) { + Text(text = it, style = MaterialTheme.typography.body1.copy(textDecoration = TextDecoration.Underline), modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)) + } + } + } + } +} + +@Composable +private fun WordsTable( + modifier: Modifier, + words: List, + onRemoveWordFrom: (Int) -> Unit, +) { + Row(modifier) { + Column(modifier = Modifier.weight(1f)) { + words.take(6).forEachIndexed { index, word -> + WordRow( + wordNumber = index + 1, + word = word, + onRemoveWordClick = { onRemoveWordFrom(index) } + ) + } + } + Spacer(Modifier.width(8.dp)) + Column(Modifier.weight(1f)) { + words.subList(6, 12).forEachIndexed { index, word -> + WordRow( + wordNumber = index + 6 + 1, + word = word, + onRemoveWordClick = { onRemoveWordFrom(index + 6) } + ) + } + } + } +} + +@Composable +private fun WordRow( + wordNumber: Int, + word: String?, + onRemoveWordClick: () -> Unit +) { + Clickable( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)), + enabled = !word.isNullOrBlank(), + onClick = onRemoveWordClick, + internalPadding = PaddingValues(4.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = String.format("#%02d -", wordNumber), + style = MaterialTheme.typography.caption.copy(fontSize = 12.sp), + modifier = Modifier.alignBy(FirstBaseline) + ) + Spacer(Modifier.width(4.dp)) + Text( + text = word ?: "...", + style = if (word != null) MaterialTheme.typography.body2 else MaterialTheme.typography.caption, + modifier = Modifier.alignBy(FirstBaseline) + ) + if (!word.isNullOrBlank()) { + Spacer(Modifier.weight(1f)) + PhoenixIcon(resourceId = R.drawable.ic_cross, tint = MaterialTheme.colors.primary) + } + } + } +} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentDetailsView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentDetailsView.kt index d46179c85..01977b0c5 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentDetailsView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentDetailsView.kt @@ -36,6 +36,7 @@ import fr.acinq.phoenix.android.components.Card import fr.acinq.phoenix.android.components.DefaultScreenHeader import fr.acinq.phoenix.android.components.DefaultScreenLayout import fr.acinq.phoenix.android.components.feedback.ErrorMessage +import fr.acinq.phoenix.android.services.LocalBackupWorker import fr.acinq.phoenix.data.WalletPaymentFetchOptions import fr.acinq.phoenix.data.WalletPaymentId import fr.acinq.phoenix.data.WalletPaymentInfo diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/security/EncryptedBackup.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/security/EncryptedBackup.kt new file mode 100644 index 000000000..763507882 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/security/EncryptedBackup.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.security + +import fr.acinq.bitcoin.ByteVector + +data class EncryptedBackup(val version: Int, val salt: ByteVector,) { + +} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/LocalBackupWorker.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/LocalBackupWorker.kt new file mode 100644 index 000000000..6244b7b03 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/LocalBackupWorker.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.services + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.Operation +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import fr.acinq.phoenix.android.BuildConfig +import fr.acinq.phoenix.android.PhoenixApplication +import fr.acinq.phoenix.android.utils.backup.LocalBackupHelper +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.seconds +import kotlin.time.toJavaDuration + +class LocalBackupWorker(val context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) { + + val log: Logger = LoggerFactory.getLogger(this::class.java) + + override suspend fun doWork(): Result { + log.info("starting local-backup-worker") + + return try { + val application = context as PhoenixApplication + val business = application.business.filterNotNull().first() + val keyManager = business.walletManager.keyManager.filterNotNull().first() + LocalBackupHelper.saveBackupToDisk(context, keyManager) + log.info("successfully saved backup file to disk") + Result.success() + } catch (e: Exception) { + log.error("error when processing local-backup job: ", e) + Result.failure() + } + } + + companion object { + private val log = LoggerFactory.getLogger(this::class.java) + const val TAG = BuildConfig.APPLICATION_ID + ".LocalBackupWorker" + const val PERIODIC_TAG = BuildConfig.APPLICATION_ID + ".LocalBackupWorkerPeriodic" + + /** Schedule a local-backup-worker job every day. */ + fun schedulePeriodic(context: Context) { + log.info("scheduling periodic local-backup-worker") + val work = PeriodicWorkRequest.Builder(LocalBackupWorker::class.java, 24, TimeUnit.HOURS, 12, TimeUnit.HOURS).addTag(PERIODIC_TAG) + WorkManager.getInstance(context).enqueueUniquePeriodicWork(PERIODIC_TAG, ExistingPeriodicWorkPolicy.UPDATE, work.build()) + } + + /** Schedule a local-backup-worker job to run once. Existing schedules are replaced. */ + fun scheduleOnce(context: Context) { + log.info("scheduling local-backup once") + val work = OneTimeWorkRequestBuilder().setInitialDelay(10.seconds.toJavaDuration()).build() + WorkManager.getInstance(context).enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, work) + } + + /** Cancel all scheduled local-backup-worker. */ + fun cancel(context: Context): Operation { + return WorkManager.getInstance(context).cancelAllWorkByTag(TAG) + } + } +} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/NodeService.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/NodeService.kt index c99dad40c..714c11f96 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/NodeService.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/NodeService.kt @@ -232,6 +232,7 @@ class NodeService : Service() { log.info("starting node from service state=${_state.value?.name} with checkLegacyChannels=$requestCheckLegacyChannels") doStartBusiness(decryptedMnemonics, requestCheckLegacyChannels) ChannelsWatcher.schedule(applicationContext) + LocalBackupWorker.schedulePeriodic(applicationContext) _state.postValue(NodeServiceState.Running) } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ResetWallet.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ResetWallet.kt index 19574ecd6..c9ac417d0 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ResetWallet.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ResetWallet.kt @@ -64,9 +64,13 @@ import fr.acinq.phoenix.android.fiatRate import fr.acinq.phoenix.android.security.SeedManager import fr.acinq.phoenix.android.utils.Converter.toPrettyString import fr.acinq.phoenix.android.utils.negativeColor +import fr.acinq.phoenix.managers.DatabaseManager +import fr.acinq.phoenix.managers.NodeParamsManager import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.slf4j.LoggerFactory @@ -95,8 +99,7 @@ class ResetWalletViewModel : ViewModel() { fun deleteWalletData( context: Context, - chain: Chain, - nodeIdHash: String, + nodeParamsManager: NodeParamsManager, onShutdownBusiness: () -> Unit, onShutdownService: () -> Unit, onPrefsClear: suspend () -> Unit, @@ -115,8 +118,9 @@ class ResetWalletViewModel : ViewModel() { state.value = ResetWalletStep.Deleting.Databases context.deleteDatabase("appdb.sqlite") - context.deleteDatabase("payments-${chain.name.lowercase()}-$nodeIdHash.sqlite") - context.deleteDatabase("channels-${chain.name.lowercase()}-$nodeIdHash.sqlite") + val nodeParams = nodeParamsManager.nodeParams.filterNotNull().first() + context.deleteDatabase(DatabaseManager.paymentsDbName(nodeParams.chain, nodeParams.nodeId)) + context.deleteDatabase(DatabaseManager.channelsDbName(nodeParams.chain, nodeParams.nodeId)) delay(500) state.value = ResetWalletStep.Deleting.Prefs @@ -167,14 +171,12 @@ fun ResetWallet( } ResetWalletStep.Confirm -> { val context = LocalContext.current - val business = business - val nodeIdHash = business.nodeParamsManager.nodeParams.value!!.nodeId.hash160().byteVector().toHex() + val nodeParamsManager = business.nodeParamsManager ReviewReset( onConfirmClick = { vm.deleteWalletData( context = context, - chain = business.chain, - nodeIdHash = nodeIdHash, + nodeParamsManager = nodeParamsManager, onShutdownBusiness = onShutdownBusiness, onShutdownService = onShutdownService, onPrefsClear = onPrefsClear, @@ -288,7 +290,9 @@ private fun ReviewReset( @Composable private fun DeletingWallet(state: ResetWalletStep.Deleting) { Column( - modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), verticalArrangement = Arrangement.spacedBy(2.dp), horizontalAlignment = Alignment.CenterHorizontally ) { @@ -324,7 +328,9 @@ private fun DeletingWallet(state: ResetWalletStep.Deleting) { private fun WalletDeleted() { val context = LocalContext.current Column( - modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), verticalArrangement = Arrangement.spacedBy(2.dp), horizontalAlignment = Alignment.CenterHorizontally ) { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/startup/StartupViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/startup/StartupViewModel.kt index 02cc30771..4174a0659 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/startup/StartupViewModel.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/startup/StartupViewModel.kt @@ -26,6 +26,7 @@ import fr.acinq.lightning.crypto.LocalKeyManager import fr.acinq.phoenix.android.security.EncryptedSeed import fr.acinq.phoenix.android.security.SeedManager import fr.acinq.phoenix.android.services.NodeService +import fr.acinq.phoenix.managers.DatabaseManager import fr.acinq.phoenix.managers.NodeParamsManager import fr.acinq.phoenix.managers.nodeIdHash import kotlinx.coroutines.CoroutineExceptionHandler @@ -96,9 +97,10 @@ class StartupViewModel : ViewModel() { decryptionState.value = StartupDecryptionState.SeedInputFallback.Error.Other(e) }) { val seed = MnemonicCode.toSeed(mnemonics = words.joinToString(" "), passphrase = "").byteVector() - val localKeyManager = LocalKeyManager(seed = seed, chain = NodeParamsManager.chain, remoteSwapInExtendedPublicKey = NodeParamsManager.remoteSwapInXpub) - val nodeIdHash = localKeyManager.nodeIdHash() - val channelsDbFile = context.getDatabasePath("channels-${NodeParamsManager.chain.name.lowercase()}-$nodeIdHash.sqlite") + val chain = NodeParamsManager.chain + val localKeyManager = LocalKeyManager(seed = seed, chain = chain, remoteSwapInExtendedPublicKey = NodeParamsManager.remoteSwapInXpub) + val nodeId = localKeyManager.nodeKeys.nodeKey.publicKey + val channelsDbFile = context.getDatabasePath(DatabaseManager.channelsDbName(chain, nodeId)) if (channelsDbFile.exists()) { decryptionState.value = StartupDecryptionState.SeedInputFallback.Success.MatchingData val encodedSeed = EncryptedSeed.fromMnemonics(words) diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/BiometricsHelper.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/BiometricsHelper.kt index 21144e003..51bff3684 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/BiometricsHelper.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/BiometricsHelper.kt @@ -19,8 +19,6 @@ package fr.acinq.phoenix.android.utils import android.content.Context import androidx.biometric.BiometricManager import androidx.biometric.BiometricPrompt -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource import androidx.fragment.app.FragmentActivity import fr.acinq.phoenix.android.R import org.slf4j.LoggerFactory diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/backup/LocalBackupHelper.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/backup/LocalBackupHelper.kt index 0371b286d..04481af99 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/backup/LocalBackupHelper.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/backup/LocalBackupHelper.kt @@ -16,20 +16,296 @@ package fr.acinq.phoenix.android.utils.backup +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import androidx.annotation.RequiresApi +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.KeyPath +import fr.acinq.bitcoin.byteVector +import fr.acinq.lightning.crypto.LocalKeyManager +import fr.acinq.phoenix.android.PhoenixApplication +import fr.acinq.phoenix.android.utils.Converter.toAbsoluteDateTimeString +import fr.acinq.phoenix.managers.DatabaseManager +import fr.acinq.phoenix.managers.nodeIdHash +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream +import javax.crypto.Cipher +import javax.crypto.SecretKey +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + /** - * This utility class provides an easy way to backup files on disk, using the - * Android Storage Access Framework. The main purpose is to backup channels or - * payments data locally, on-device, so that these data can be restored later, - * or imported on another device. - * - * Files are NOT stored in the Phoenix-specific folders reserved by the OS. - * Instead, they are stored outside, in public directories, like Documents. + * This utility class provides helps backing up the channels/payments database + * in an encrypted zip file, stored on disk in a public folder of the device. * - * These files are accessible by other apps, and as such are encrypted using - * a key derived from the seed. - * - * These files are NOT removed when the app is uninstalled. + * The backup file is NOT removed when the app is uninstalled. */ -class LocalBackupHelper { -} \ No newline at end of file +object LocalBackupHelper { + + private val log = LoggerFactory.getLogger(this::class.java) + + /** The backup file is stored in the Documents directory of Android. */ + private val backupDir = "${Environment.DIRECTORY_DOCUMENTS}/phoenix-backup" + + private suspend fun prepareBackupContent(context: Context): ByteArray { + return withContext(Dispatchers.IO) { + log.info("preparing data...") + val business = (context as PhoenixApplication).business.filterNotNull().first() + val nodeParams = business.nodeParamsManager.nodeParams.filterNotNull().first() + val channelsDbFile = context.getDatabasePath(DatabaseManager.channelsDbName(nodeParams.chain, nodeParams.nodeId)) + val paymentsDbFile = context.getDatabasePath(DatabaseManager.paymentsDbName(nodeParams.chain, nodeParams.nodeId)) + + val bos = ByteArrayOutputStream() + ZipOutputStream(bos).use { zos -> + log.info("zipping channels db...") + FileInputStream(channelsDbFile).use { fis -> + zos.putNextEntry(ZipEntry(channelsDbFile.name)) + zos.write(fis.readBytes()) + } + log.info("zipping payments db file...") + FileInputStream(paymentsDbFile).use { fis -> + zos.putNextEntry(ZipEntry(paymentsDbFile.name)) + zos.write(fis.readBytes()) + } + } + + bos.toByteArray() + } + } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun createBackupFileUri(context: Context, fileName: String): Uri { + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + put(MediaStore.MediaColumns.MIME_TYPE, "application/octet-stream") + put(MediaStore.MediaColumns.RELATIVE_PATH, backupDir) + } + return context.contentResolver.insert(MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), values) + ?: throw RuntimeException("failed to insert uri record for backup file") + } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun getBackupFileUri(context: Context, fileName: String): Pair? { + val contentUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL) + // columns to return -- we want the name & modified timestamp + val projection = arrayOf( + MediaStore.Files.FileColumns._ID, + MediaStore.Files.FileColumns.DISPLAY_NAME, + MediaStore.Files.FileColumns.DATE_MODIFIED, + ) + // filter on the file's name + val selection = "${MediaStore.Files.FileColumns.DISPLAY_NAME} = ? AND ${MediaStore.Files.FileColumns.OWNER_PACKAGE_NAME}" + val selectionArgs = arrayOf(fileName) + val resolver = context.contentResolver + + return resolver.query( + contentUri, + projection, + selection, + selectionArgs, + null, null + )?.use { cursor -> + val idColumn = cursor.getColumnIndex(MediaStore.Files.FileColumns._ID) + val nameColumn = cursor.getColumnIndex(MediaStore.Files.FileColumns.DISPLAY_NAME) + val modifiedAtColumn = cursor.getColumnIndex(MediaStore.Files.FileColumns.DATE_MODIFIED) + if (cursor.moveToNext()) { + val fileId = cursor.getLong(idColumn) + val actualFileName = cursor.getString(nameColumn) + val modifiedAt = cursor.getLong(modifiedAtColumn) * 1000 + log.info("found backup file with name=$actualFileName modified_at=${modifiedAt.toAbsoluteDateTimeString()}") + modifiedAt to ContentUris.withAppendedId(contentUri, fileId) + } else { + log.info("no backup file found for name=$fileName") + null + } + } + } + + private fun getBackupFileName(keyManager: LocalKeyManager): String { + return "phoenix-${keyManager.chain.name.lowercase()}-${keyManager.nodeIdHash().take(7)}.bak" + } + + /** + * Write an encrypted zip file of the payments/channels database, and store it in a public folder of the device. + * Since we're using the media store API for that, no permission is required. + */ + suspend fun saveBackupToDisk(context: Context, keyManager: LocalKeyManager) { + val encryptedBackup = try { + val data = prepareBackupContent(context) + EncryptedBackup.encrypt( + version = EncryptedBackup.Version.V1, + data = data, + keyManager = keyManager + ) + } catch (e: Exception) { + throw RuntimeException("failed to encrypt backup file", e) + } + + val fileName = getBackupFileName(keyManager) + + log.debug("saving encrypted backup to private dir...") + val internalBackup = File(context.filesDir, "phoenix-backup") + val internalBackupFile = File(internalBackup, fileName) + internalBackupFile.writeBytes(encryptedBackup.write()) + log.debug("encrypted backup successfully saved to private dir") + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + log.debug("saving encrypted backup to public dir through mediastore api...") + val resolver = context.contentResolver + + val uri = getBackupFileUri(context, fileName)?.second ?: createBackupFileUri(context, fileName) + resolver.openOutputStream(uri, "w")?.use { outputStream -> + val array = encryptedBackup.write() + outputStream.write(array) + log.debug("encrypted backup successfully saved to public dir ($uri)") + } ?: run { + log.error("public backup failed: cannot open output stream for uri=$uri") + } + } + } + + @RequiresApi(Build.VERSION_CODES.Q) + fun getBackupData(context: Context, keyManager: LocalKeyManager): EncryptedBackup? { + val (_, uri) = getBackupFileUri(context, getBackupFileName(keyManager)) ?: return null + val resolver = context.contentResolver + val data = resolver.openInputStream(uri).use { + it!!.readBytes() + } + return EncryptedBackup.read(data) + } + + fun resolveUriContent(context: Context, uri: Uri): EncryptedBackup? { + val resolver = context.contentResolver + val data = resolver.openInputStream(uri)?.use { + it.readBytes() + } + return data?.let { EncryptedBackup.read(it) } + } + + /** Extracts files from zip - folders are unhandled. */ + fun unzipData(data: ByteVector): Map { + ByteArrayInputStream(data.toByteArray()).use { bis -> + ZipInputStream(bis).use { zis -> + val files = mutableMapOf() + var zipEntry = zis.nextEntry + while (zipEntry != null) { + ByteArrayOutputStream().use { bos -> + bos.write(zis.readBytes()) + files.put(zipEntry.name, bos.toByteArray()) + } + zipEntry = zis.nextEntry + } + return files.toMap() + } + } + } + + /** Restore a database file to the app's database folder. If restoring a channels database, [canOverwrite] should ALWAYS be false. */ + fun restoreDbFile(context: Context, fileName: String, fileData: ByteArray, canOverwrite: Boolean = false) { + val dbFile = context.getDatabasePath(fileName) + if (dbFile.exists() && !canOverwrite) { + throw RuntimeException("cannot overwrite db file=$fileName") + } else { + FileOutputStream(dbFile, false).use { fos -> + fos.write(fileData) + fos.flush() + } + } + } +} + +data class EncryptedBackup(val version: Version, val iv: IvParameterSpec, val ciphertext: ByteVector) { + + enum class Version(val code: Byte, val algorithm: String) { + V1(1, "AES/CBC/PKCS5PADDING") + } + + fun decrypt(keyManager: LocalKeyManager): ByteVector { + val key = getKeyForVersion(version, keyManager) + val cipher = Cipher.getInstance(version.algorithm) + cipher.init(Cipher.DECRYPT_MODE, key, iv) + return cipher.doFinal(ciphertext.toByteArray()).byteVector() + } + + fun write(): ByteArray { + return when (version) { + Version.V1 -> { + val bos = ByteArrayOutputStream() + bos.write(version.code.toInt()) + bos.write(iv.iv) + bos.write(ciphertext.toByteArray()) + bos.toByteArray() + } + } + } + + companion object { + val log: Logger = LoggerFactory.getLogger(this::class.java) + + /** Return the encryption key for the given [version]. */ + fun getKeyForVersion(version: Version, keyManager: LocalKeyManager): SecretKey { + when (version) { + Version.V1 -> { + val key = keyManager.privateKey(KeyPath("m/150'/1'/0'")).value.toByteArray() + return SecretKeySpec(key, 0, 32, "AES") + } + } + } + + /** + * Encrypt [data] using a key from [keyManager]. The key used depends on [version]. + * @return an [EncryptedBackup] object, containing the encrypted payload, the version and the IV. + */ + fun encrypt(version: Version = Version.V1, data: ByteArray, keyManager: LocalKeyManager): EncryptedBackup { + if (version == Version.V1) { + val key = getKeyForVersion(version, keyManager) + val cipher = Cipher.getInstance(version.algorithm) + + cipher.init(Cipher.ENCRYPT_MODE, key) + val ciphertext = cipher.doFinal(data).byteVector() + + return EncryptedBackup(version, iv = IvParameterSpec(cipher.iv), ciphertext = ciphertext) + } else { + throw RuntimeException("unhandled version=$version") + } + } + + /** Extract an [EncryptedBackup] object from a blob. Throw if object is invalid. */ + fun read(data: ByteArray): EncryptedBackup? { + return ByteArrayInputStream(data).use { bis -> + when (val version = bis.read().toByte()) { + Version.V1.code -> { + val iv = ByteArray(16) + bis.read(iv, 0, 16) + val remainingBytes = bis.available() + val ciphertext = ByteArray(remainingBytes) + bis.read(ciphertext, 0, remainingBytes) + EncryptedBackup(version = Version.V1, iv = IvParameterSpec(iv), ciphertext = ciphertext.byteVector()) + } + + else -> { + throw RuntimeException("unhandled version=$version") + } + } + } + } + } +} diff --git a/phoenix-android/src/main/res/drawable/ic_cancel.xml b/phoenix-android/src/main/res/drawable/ic_cancel.xml new file mode 100644 index 000000000..25b6c4603 --- /dev/null +++ b/phoenix-android/src/main/res/drawable/ic_cancel.xml @@ -0,0 +1,19 @@ + + + + diff --git a/phoenix-android/src/main/res/xml/backup_rules.xml b/phoenix-android/src/main/res/xml/backup_rules.xml index 559a13023..6d6ed7afb 100644 --- a/phoenix-android/src/main/res/xml/backup_rules.xml +++ b/phoenix-android/src/main/res/xml/backup_rules.xml @@ -1,21 +1,26 @@ - - + + + + + + - - - + + + - diff --git a/phoenix-android/src/main/res/xml/backup_rules_before_12.xml b/phoenix-android/src/main/res/xml/backup_rules_before_12.xml index 4135c8c6f..e3158d0f4 100644 --- a/phoenix-android/src/main/res/xml/backup_rules_before_12.xml +++ b/phoenix-android/src/main/res/xml/backup_rules_before_12.xml @@ -3,11 +3,11 @@ - + + path="phoenix-backup/" /> if (nodeParams == null) return@collect log.debug { "nodeParams available: building databases..." } - - val nodeIdHash = nodeParams.nodeId.hash160().byteVector().toHex() val channelsDb = SqliteChannelsDb( - driver = createChannelsDbDriver(ctx, chain, nodeIdHash) + driver = createChannelsDbDriver(ctx, channelsDbName(chain, nodeParams.nodeId)) ) val paymentsDb = SqlitePaymentsDb( loggerFactory = loggerFactory, - driver = createPaymentsDbDriver(ctx, chain, nodeIdHash), + driver = createPaymentsDbDriver(ctx, paymentsDbName(chain, nodeParams.nodeId)), currencyManager = currencyManager ) log.debug { "databases object created" } @@ -74,4 +73,19 @@ class DatabaseManager( val db = databases.filterNotNull().first() return db.payments as SqlitePaymentsDb } + + suspend fun channelsDb(): SqliteChannelsDb { + val db = databases.filterNotNull().first() + return db.channels as SqliteChannelsDb + } + + companion object { + fun channelsDbName(chain: Chain, nodeId: PublicKey): String { + return "channels-${chain.name.lowercase()}-${nodeId.hash160().byteVector().toHex()}.sqlite" + } + + fun paymentsDbName(chain: Chain, nodeId: PublicKey): String { + return "payments-${chain.name.lowercase()}-${nodeId.hash160().byteVector().toHex()}.sqlite" + } + } } \ No newline at end of file diff --git a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/iosDbFactory.kt b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/iosDbFactory.kt index 12ec20df2..05df7b97b 100644 --- a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/iosDbFactory.kt +++ b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/iosDbFactory.kt @@ -17,27 +17,25 @@ package fr.acinq.phoenix.db import co.touchlab.sqliter.DatabaseConfiguration -import app.cash.sqldelight.db.SqlDriver -import app.cash.sqldelight.driver.native.NativeSqliteDriver -import app.cash.sqldelight.driver.native.wrapConnection +import com.squareup.sqldelight.db.SqlDriver +import com.squareup.sqldelight.drivers.native.NativeSqliteDriver +import com.squareup.sqldelight.drivers.native.wrapConnection import fr.acinq.bitcoin.Chain import fr.acinq.phoenix.utils.PlatformContext import fr.acinq.phoenix.utils.getDatabaseFilesDirectoryPath actual fun createChannelsDbDriver( ctx: PlatformContext, - chain: Chain, - nodeIdHash: String + fileName: String, ): SqlDriver { val schema = ChannelsDatabase.Schema - val name = "channels-${chain.name.lowercase()}-$nodeIdHash.sqlite" // The foreign_keys constraint needs to be set via the DatabaseConfiguration: // https://github.com/cashapp/sqldelight/issues/1356 val dbDir = getDatabaseFilesDirectoryPath(ctx) val configuration = DatabaseConfiguration( - name = name, + name = fileName, version = schema.version.toInt(), extendedConfig = DatabaseConfiguration.Extended( basePath = dbDir, @@ -55,15 +53,13 @@ actual fun createChannelsDbDriver( actual fun createPaymentsDbDriver( ctx: PlatformContext, - chain: Chain, - nodeIdHash: String + fileName: String, ): SqlDriver { val schema = PaymentsDatabase.Schema - val name = "payments-${chain.name.lowercase()}-$nodeIdHash.sqlite" val dbDir = getDatabaseFilesDirectoryPath(ctx) val configuration = DatabaseConfiguration( - name = name, + name = fileName, version = schema.version.toInt(), extendedConfig = DatabaseConfiguration.Extended( basePath = dbDir, From dd409ede759bc6386c501e17113e06f721d08798 Mon Sep 17 00:00:00 2001 From: Dominique Padiou <5765435+dpad85@users.noreply.github.com> Date: Thu, 25 Apr 2024 18:26:35 +0200 Subject: [PATCH 4/9] Revert auto cloud backup changes For now we'll keep the backup file local. --- phoenix-android/src/main/AndroidManifest.xml | 7 +++-- .../android/utils/backup/LocalBackupHelper.kt | 6 ----- .../src/main/res/xml/backup_rules.xml | 27 ------------------- .../main/res/xml/backup_rules_before_12.xml | 20 -------------- 4 files changed, 3 insertions(+), 57 deletions(-) delete mode 100644 phoenix-android/src/main/res/xml/backup_rules.xml delete mode 100644 phoenix-android/src/main/res/xml/backup_rules_before_12.xml diff --git a/phoenix-android/src/main/AndroidManifest.xml b/phoenix-android/src/main/AndroidManifest.xml index 3ea20b054..e6518cdaa 100644 --- a/phoenix-android/src/main/AndroidManifest.xml +++ b/phoenix-android/src/main/AndroidManifest.xml @@ -12,12 +12,11 @@ = Build.VERSION_CODES.Q) { log.debug("saving encrypted backup to public dir through mediastore api...") val resolver = context.contentResolver diff --git a/phoenix-android/src/main/res/xml/backup_rules.xml b/phoenix-android/src/main/res/xml/backup_rules.xml deleted file mode 100644 index 6d6ed7afb..000000000 --- a/phoenix-android/src/main/res/xml/backup_rules.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/phoenix-android/src/main/res/xml/backup_rules_before_12.xml b/phoenix-android/src/main/res/xml/backup_rules_before_12.xml deleted file mode 100644 index e3158d0f4..000000000 --- a/phoenix-android/src/main/res/xml/backup_rules_before_12.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file From fec42db25a1fe1b43a4f24787fa17b3cdb0e117d Mon Sep 17 00:00:00 2001 From: Dominique Padiou <5765435+dpad85@users.noreply.github.com> Date: Thu, 25 Apr 2024 18:29:33 +0200 Subject: [PATCH 5/9] Clean up commented code --- .../android/initwallet/InitViewModel.kt | 96 +------------------ 1 file changed, 1 insertion(+), 95 deletions(-) diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/InitViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/InitViewModel.kt index 78a546a43..b5324151a 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/InitViewModel.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/InitViewModel.kt @@ -91,98 +91,4 @@ abstract class InitWalletViewModel: ViewModel() { } } } -} - - -// /** State of the restore wallet view */ -// var restoreWalletState by mutableStateOf(RestoreWalletViewState.Disclaimer) -// -// var checkBackupState by mutableStateOf(CheckBackupState.Init) -// - - - - - - - -// fun attemptBackupRestore(context: Context, seed: ByteArray, onRestoreDone: () -> Unit) { -// if (checkBackupState is CheckBackupState.Checking) return -// log.info("checking for backup files") -// -//// fun onRestoreDoneMain() { -//// viewModelScope.launch(Dispatchers.Main) { -//// onRestoreDone() -//// } -//// } -// -// checkBackupState = CheckBackupState.Checking.LookingForFile -// viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e -> -// log.error("error when checking backup: ", e) -// checkBackupState = CheckBackupState.Done.Failed.Error(e) -// // onRestoreDoneMain() -// }) { -// if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { -// log.info("unsupported android version=${Build.VERSION.SDK_INT}") -// checkBackupState = CheckBackupState.Done.Failed.UnsupportedAndroidVersion -// //onRestoreDoneMain() -// return@launch -// } -// val keyManager = LocalKeyManager(seed = seed.byteVector(), chain = NodeParamsManager.chain, remoteSwapInExtendedPublicKey = NodeParamsManager.remoteSwapInXpub) -// when (val encryptedBackup = LocalBackupHelper.getBackupData(context, keyManager)) { -// null -> { -// log.info("no backup found") -// checkBackupState = CheckBackupState.Done.NoBackupFound -// //onRestoreDoneMain() -// return@launch -// } -// else -> { -// log.info("found backup, decrypting file...") -// checkBackupState = CheckBackupState.Checking.Decrypting -// val data = try { -// encryptedBackup.decrypt(keyManager) -// } catch (e: Exception) { -// log.error("cannot decrypt backup file: ", e) -// checkBackupState = CheckBackupState.Done.Failed.CannotDecrypt(encryptedBackup) -// //onRestoreDoneMain() -// return@launch -// } -// -// log.info("unzipping backup...") -// val files = LocalBackupHelper.unzipData(data) -// val nodeId = keyManager.nodeKeys.nodeKey.publicKey -// val channelsDbEntry = files.filterKeys { it == DatabaseManager.channelsDbName(NodeParamsManager.chain, nodeId) }.entries.firstOrNull() -// val paymentsDbEntry = files.filterKeys { it == DatabaseManager.paymentsDbName(NodeParamsManager.chain, nodeId) }.entries.firstOrNull() -// -// if (channelsDbEntry == null || paymentsDbEntry == null) { -// log.error("missing channels or payments database file in zip backup") -// checkBackupState = CheckBackupState.Done.Failed.InvalidName -// //onRestoreDoneMain() -// return@launch -// } else { -// log.info("restoring channels and payments database files") -// checkBackupState = try { -// // LocalBackupHelper.restoreFilesToPrivateDir(context, channelsDbEntry.key, channelsDbEntry.value, paymentsDbEntry.key, paymentsDbEntry.value) -// log.info("backup files have been restored") -// CheckBackupState.Done.BackupRestored -// } catch (e: Exception) { -// log.error("channels or payments files already exist in database directory") -// CheckBackupState.Done.Failed.AlreadyExist -// } -// // onRestoreDoneMain() -// } -// } -// } -// } -// } -// -// class Factory( -// private val controllerFactory: ControllerFactory, -// private val getController: ControllerFactory.() -> InitializationController -// ) : ViewModelProvider.Factory { -// override fun create(modelClass: Class): T { -// @Suppress("UNCHECKED_CAST") -// return InitViewModel(controllerFactory.getController()) as T -// } -// } -//} \ No newline at end of file +} \ No newline at end of file From f4eae0e42ca54528a009da2ff9defbbee6fbb2ff Mon Sep 17 00:00:00 2001 From: Dominique Padiou <5765435+dpad85@users.noreply.github.com> Date: Mon, 29 Apr 2024 18:35:24 +0200 Subject: [PATCH 6/9] Fix backup definition in legacy metadata --- phoenix-legacy/src/main/AndroidManifest.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phoenix-legacy/src/main/AndroidManifest.xml b/phoenix-legacy/src/main/AndroidManifest.xml index 35bd8ea5d..8acc0f0c7 100644 --- a/phoenix-legacy/src/main/AndroidManifest.xml +++ b/phoenix-legacy/src/main/AndroidManifest.xml @@ -25,7 +25,7 @@ Date: Tue, 30 Apr 2024 16:33:24 +0200 Subject: [PATCH 7/9] Clean up old backup file after restore to prevent permission issues --- .../restore/RestorePaymentsBackupView.kt | 20 +++++-- .../restore/RestoreWalletViewModel.kt | 5 ++ .../android/services/LocalBackupWorker.kt | 5 +- .../android/utils/backup/LocalBackupHelper.kt | 53 +++++++++++++------ 4 files changed, 60 insertions(+), 23 deletions(-) diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/RestorePaymentsBackupView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/RestorePaymentsBackupView.kt index 33c4803c6..d3335a874 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/RestorePaymentsBackupView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/RestorePaymentsBackupView.kt @@ -16,6 +16,11 @@ package fr.acinq.phoenix.android.initwallet.restore +import android.content.Context +import android.content.Intent +import android.os.Build +import android.provider.DocumentsContract +import android.provider.MediaStore import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -52,7 +57,16 @@ fun RestorePaymentsBackupView( BackHandler { /* Disable back button */ } val filePickerLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.OpenDocument(), + contract = object : ActivityResultContracts.OpenDocument() { + override fun createIntent(context: Context, input: Array): Intent { + val intent = super.createIntent(context, input) + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)) + } else { + intent + } + } + }, onResult = { uri -> if (uri != null) { vm.restorePaymentsBackup(context, words = words, uri = uri, onBackupRestoreDone = onBackupRestoreDone) @@ -67,10 +81,10 @@ fun RestorePaymentsBackupView( verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text(text = "You can manually restore your payments history by importing a Phoenix backup file, if one exists.") - Text(text = "Look for a phoenix.bak file in your Documents folder. Note that older versions of Phoenix (before v2.3.0) did not generate backups.") + Text(text = "Look for a phoenix.bak file in your Documents folder.") } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(32.dp)) Column( modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/RestoreWalletViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/RestoreWalletViewModel.kt index 4aadad378..3d407b5a5 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/RestoreWalletViewModel.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/RestoreWalletViewModel.kt @@ -18,6 +18,7 @@ package fr.acinq.phoenix.android.initwallet.restore import android.content.Context import android.net.Uri +import android.os.Build import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -163,6 +164,10 @@ class RestoreWalletViewModel: InitWalletViewModel() { LocalBackupHelper.restoreDbFile(context, paymentsDbEntry.key, paymentsDbEntry.value) log.info("payments db has been restored") restoreBackupState = RestoreBackupState.Done.BackupRestored + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + LocalBackupHelper.cleanUpOldBackupFile(context, keyManager, encryptedBackup, uri) + log.debug("old backup file cleaned up") + } delay(1000) viewModelScope.launch(Dispatchers.Main) { onBackupRestoreDone() diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/LocalBackupWorker.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/LocalBackupWorker.kt index 6244b7b03..842a32c52 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/LocalBackupWorker.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/LocalBackupWorker.kt @@ -33,8 +33,6 @@ import kotlinx.coroutines.flow.first import org.slf4j.Logger import org.slf4j.LoggerFactory import java.util.concurrent.TimeUnit -import kotlin.time.Duration.Companion.seconds -import kotlin.time.toJavaDuration class LocalBackupWorker(val context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) { @@ -49,6 +47,7 @@ class LocalBackupWorker(val context: Context, workerParams: WorkerParameters) : val keyManager = business.walletManager.keyManager.filterNotNull().first() LocalBackupHelper.saveBackupToDisk(context, keyManager) log.info("successfully saved backup file to disk") + Result.success() } catch (e: Exception) { log.error("error when processing local-backup job: ", e) @@ -71,7 +70,7 @@ class LocalBackupWorker(val context: Context, workerParams: WorkerParameters) : /** Schedule a local-backup-worker job to run once. Existing schedules are replaced. */ fun scheduleOnce(context: Context) { log.info("scheduling local-backup once") - val work = OneTimeWorkRequestBuilder().setInitialDelay(10.seconds.toJavaDuration()).build() + val work = OneTimeWorkRequestBuilder().build() WorkManager.getInstance(context).enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, work) } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/backup/LocalBackupHelper.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/backup/LocalBackupHelper.kt index 8198fcf93..51fa2c6cd 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/backup/LocalBackupHelper.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/backup/LocalBackupHelper.kt @@ -19,9 +19,11 @@ package fr.acinq.phoenix.android.utils.backup import android.content.ContentUris import android.content.ContentValues import android.content.Context +import android.content.Intent import android.net.Uri import android.os.Build import android.os.Environment +import android.provider.DocumentsContract import android.provider.MediaStore import androidx.annotation.RequiresApi import fr.acinq.bitcoin.ByteVector @@ -75,12 +77,12 @@ object LocalBackupHelper { val bos = ByteArrayOutputStream() ZipOutputStream(bos).use { zos -> - log.info("zipping channels db...") + log.debug("zipping channels db...") FileInputStream(channelsDbFile).use { fis -> zos.putNextEntry(ZipEntry(channelsDbFile.name)) zos.write(fis.readBytes()) } - log.info("zipping payments db file...") + log.debug("zipping payments db file...") FileInputStream(paymentsDbFile).use { fis -> zos.putNextEntry(ZipEntry(paymentsDbFile.name)) zos.write(fis.readBytes()) @@ -98,13 +100,13 @@ object LocalBackupHelper { put(MediaStore.MediaColumns.MIME_TYPE, "application/octet-stream") put(MediaStore.MediaColumns.RELATIVE_PATH, backupDir) } - return context.contentResolver.insert(MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), values) + return context.contentResolver.insert(MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), values) ?: throw RuntimeException("failed to insert uri record for backup file") } @RequiresApi(Build.VERSION_CODES.Q) private fun getBackupFileUri(context: Context, fileName: String): Pair? { - val contentUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL) + val contentUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) // columns to return -- we want the name & modified timestamp val projection = arrayOf( MediaStore.Files.FileColumns._ID, @@ -112,7 +114,7 @@ object LocalBackupHelper { MediaStore.Files.FileColumns.DATE_MODIFIED, ) // filter on the file's name - val selection = "${MediaStore.Files.FileColumns.DISPLAY_NAME} = ? AND ${MediaStore.Files.FileColumns.OWNER_PACKAGE_NAME}" + val selection = "${MediaStore.Files.FileColumns.DISPLAY_NAME} = ?" val selectionArgs = arrayOf(fileName) val resolver = context.contentResolver @@ -130,7 +132,7 @@ object LocalBackupHelper { val fileId = cursor.getLong(idColumn) val actualFileName = cursor.getString(nameColumn) val modifiedAt = cursor.getLong(modifiedAtColumn) * 1000 - log.info("found backup file with name=$actualFileName modified_at=${modifiedAt.toAbsoluteDateTimeString()}") + log.debug("found backup file with name=$actualFileName modified_at=${modifiedAt.toAbsoluteDateTimeString()}") modifiedAt to ContentUris.withAppendedId(contentUri, fileId) } else { log.info("no backup file found for name=$fileName") @@ -162,17 +164,22 @@ object LocalBackupHelper { val fileName = getBackupFileName(keyManager) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - log.debug("saving encrypted backup to public dir through mediastore api...") - val resolver = context.contentResolver - - val uri = getBackupFileUri(context, fileName)?.second ?: createBackupFileUri(context, fileName) - resolver.openOutputStream(uri, "w")?.use { outputStream -> - val array = encryptedBackup.write() - outputStream.write(array) - log.debug("encrypted backup successfully saved to public dir ($uri)") - } ?: run { - log.error("public backup failed: cannot open output stream for uri=$uri") - } + saveBackupThroughMediastore(context, encryptedBackup, fileName) + } + } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun saveBackupThroughMediastore(context: Context, encryptedBackup: EncryptedBackup, fileName: String) { + log.debug("saving encrypted backup to public dir through mediastore api...") + val resolver = context.contentResolver + + val uri = getBackupFileUri(context, fileName)?.second ?: createBackupFileUri(context, fileName) + resolver.openOutputStream(uri, "w")?.use { outputStream -> + val array = encryptedBackup.write() + outputStream.write(array) + log.debug("encrypted backup successfully saved to public dir ($uri)") + } ?: run { + log.error("public backup failed: cannot open output stream for uri=$uri") } } @@ -188,12 +195,24 @@ object LocalBackupHelper { fun resolveUriContent(context: Context, uri: Uri): EncryptedBackup? { val resolver = context.contentResolver + val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION val data = resolver.openInputStream(uri)?.use { it.readBytes() } return data?.let { EncryptedBackup.read(it) } } + @RequiresApi(Build.VERSION_CODES.Q) + fun cleanUpOldBackupFile(context: Context, keyManager: LocalKeyManager, encryptedBackup: EncryptedBackup, oldBackupUri: Uri) { + val fileName = getBackupFileName(keyManager) + val resolver = context.contentResolver + // old backup file needs to be renamed otherwise it will prevent new file from being written -- and it cannot be moved/deleted + // later since the file is not attributed to this app installation + DocumentsContract.renameDocument(resolver, oldBackupUri, "$fileName.old") + // write a new file through the mediastore API so that it's attributed to this app installation + saveBackupThroughMediastore(context, encryptedBackup, fileName) + } + /** Extracts files from zip - folders are unhandled. */ fun unzipData(data: ByteVector): Map { ByteArrayInputStream(data.toByteArray()).use { bis -> From 63835f19e39accb67959eeaee08fca6d2e355d82 Mon Sep 17 00:00:00 2001 From: Dominique Padiou <5765435+dpad85@users.noreply.github.com> Date: Fri, 23 Aug 2024 14:54:40 +0200 Subject: [PATCH 8/9] Fix string resource --- .../phoenix/android/initwallet/restore/RestoreWalletView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/RestoreWalletView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/RestoreWalletView.kt index 56a391582..fd1f5d177 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/RestoreWalletView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/RestoreWalletView.kt @@ -143,7 +143,7 @@ private fun DisclaimerView( Text(stringResource(R.string.restore_disclaimer_message)) } Checkbox( - text = stringResource(R.string.restore_disclaimer_checkbox), + text = stringResource(R.string.utils_ack), checked = hasCheckedWarning, onCheckedChange = { hasCheckedWarning = it }, ) From 5a2f711b93aee219a4e0142578d577914171f122 Mon Sep 17 00:00:00 2001 From: Dominique Padiou <5765435+dpad85@users.noreply.github.com> Date: Tue, 10 Sep 2024 18:04:51 +0200 Subject: [PATCH 9/9] Light refactoring and add new comments --- .../fr/acinq/phoenix/android/AppView.kt | 4 +- .../restore/RestoreWalletViewModel.kt | 2 +- .../android/services/LocalBackupWorker.kt | 18 ++++-- .../phoenix/android/services/NodeService.kt | 4 +- .../android/utils/backup/LocalBackupHelper.kt | 57 ++++++++++--------- 5 files changed, 50 insertions(+), 35 deletions(-) diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt index 7f9179439..168e97e69 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt @@ -537,7 +537,9 @@ fun AppView( val userPrefs = userPrefs val exchangeRates = fiatRates lastCompletedPayment?.let { payment -> - LocalBackupWorker.scheduleOnce(context) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + LocalBackupWorker.scheduleOnce(context) + } LaunchedEffect(key1 = payment.walletPaymentId()) { try { if (isDataMigrationExpected == false) { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/RestoreWalletViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/RestoreWalletViewModel.kt index 3d407b5a5..3ba440d58 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/RestoreWalletViewModel.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/RestoreWalletViewModel.kt @@ -166,7 +166,7 @@ class RestoreWalletViewModel: InitWalletViewModel() { restoreBackupState = RestoreBackupState.Done.BackupRestored if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { LocalBackupHelper.cleanUpOldBackupFile(context, keyManager, encryptedBackup, uri) - log.debug("old backup file cleaned up") + log.info("old backup files cleaned up") } delay(1000) viewModelScope.launch(Dispatchers.Main) { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/LocalBackupWorker.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/LocalBackupWorker.kt index 842a32c52..5603bc46d 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/LocalBackupWorker.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/LocalBackupWorker.kt @@ -17,6 +17,8 @@ package fr.acinq.phoenix.android.services import android.content.Context +import android.os.Build +import androidx.annotation.RequiresApi import androidx.work.CoroutineWorker import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingWorkPolicy @@ -34,12 +36,17 @@ import org.slf4j.Logger import org.slf4j.LoggerFactory import java.util.concurrent.TimeUnit +/** + * Writes a backup of the channels & payments database to disk, using [LocalBackupHelper]. + * Requires API 29+ (Q) because we use the MediaStore API to access the file system. + */ class LocalBackupWorker(val context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) { val log: Logger = LoggerFactory.getLogger(this::class.java) override suspend fun doWork(): Result { - log.info("starting local-backup-worker") + // should never happen, since the [schedule] and [scheduleOnce] methods are annotated. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return Result.failure() return try { val application = context as PhoenixApplication @@ -50,26 +57,29 @@ class LocalBackupWorker(val context: Context, workerParams: WorkerParameters) : Result.success() } catch (e: Exception) { - log.error("error when processing local-backup job: ", e) + log.error("error when processing $name: ", e) Result.failure() } } companion object { private val log = LoggerFactory.getLogger(this::class.java) + val name = "local-backup-worker" const val TAG = BuildConfig.APPLICATION_ID + ".LocalBackupWorker" const val PERIODIC_TAG = BuildConfig.APPLICATION_ID + ".LocalBackupWorkerPeriodic" /** Schedule a local-backup-worker job every day. */ + @RequiresApi(Build.VERSION_CODES.Q) fun schedulePeriodic(context: Context) { - log.info("scheduling periodic local-backup-worker") + log.info("scheduling periodic $name") val work = PeriodicWorkRequest.Builder(LocalBackupWorker::class.java, 24, TimeUnit.HOURS, 12, TimeUnit.HOURS).addTag(PERIODIC_TAG) WorkManager.getInstance(context).enqueueUniquePeriodicWork(PERIODIC_TAG, ExistingPeriodicWorkPolicy.UPDATE, work.build()) } /** Schedule a local-backup-worker job to run once. Existing schedules are replaced. */ + @RequiresApi(Build.VERSION_CODES.Q) fun scheduleOnce(context: Context) { - log.info("scheduling local-backup once") + log.info("scheduling $name once") val work = OneTimeWorkRequestBuilder().build() WorkManager.getInstance(context).enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, work) } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/NodeService.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/NodeService.kt index 948fa4ed7..5295716fa 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/NodeService.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/NodeService.kt @@ -233,7 +233,9 @@ class NodeService : Service() { doStartBusiness(decryptedMnemonics, requestCheckLegacyChannels) ChannelsWatcher.schedule(applicationContext) DailyConnect.schedule(applicationContext) - LocalBackupWorker.schedulePeriodic(applicationContext) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + LocalBackupWorker.schedulePeriodic(applicationContext) + } _state.postValue(NodeServiceState.Running) } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/backup/LocalBackupHelper.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/backup/LocalBackupHelper.kt index 51fa2c6cd..cf13e839f 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/backup/LocalBackupHelper.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/backup/LocalBackupHelper.kt @@ -42,7 +42,6 @@ import org.slf4j.Logger import org.slf4j.LoggerFactory import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream -import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.util.zip.ZipEntry @@ -55,10 +54,13 @@ import javax.crypto.spec.SecretKeySpec /** - * This utility class provides helps backing up the channels/payments database - * in an encrypted zip file, stored on disk in a public folder of the device. + * This utility class provides helpers to back up the channels/payments databases + * in an encrypted zip file and store them on disk, in a public folder of the device. * * The backup file is NOT removed when the app is uninstalled. + * + * Requires API 29+ (Q) because of the MediaStore API. Thanks to this API, no additional + * permissions are required to access the file system. */ object LocalBackupHelper { @@ -67,6 +69,9 @@ object LocalBackupHelper { /** The backup file is stored in the Documents directory of Android. */ private val backupDir = "${Environment.DIRECTORY_DOCUMENTS}/phoenix-backup" + @RequiresApi(Build.VERSION_CODES.Q) + private val volumeUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + private suspend fun prepareBackupContent(context: Context): ByteArray { return withContext(Dispatchers.IO) { log.info("preparing data...") @@ -100,29 +105,25 @@ object LocalBackupHelper { put(MediaStore.MediaColumns.MIME_TYPE, "application/octet-stream") put(MediaStore.MediaColumns.RELATIVE_PATH, backupDir) } - return context.contentResolver.insert(MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), values) + return context.contentResolver.insert(volumeUri, values) ?: throw RuntimeException("failed to insert uri record for backup file") } @RequiresApi(Build.VERSION_CODES.Q) private fun getBackupFileUri(context: Context, fileName: String): Pair? { - val contentUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) - // columns to return -- we want the name & modified timestamp - val projection = arrayOf( + val columnsToGet = arrayOf( MediaStore.Files.FileColumns._ID, MediaStore.Files.FileColumns.DISPLAY_NAME, MediaStore.Files.FileColumns.DATE_MODIFIED, ) - // filter on the file's name - val selection = "${MediaStore.Files.FileColumns.DISPLAY_NAME} = ?" - val selectionArgs = arrayOf(fileName) - val resolver = context.contentResolver - - return resolver.query( - contentUri, - projection, - selection, - selectionArgs, + val filter = "${MediaStore.Files.FileColumns.DISPLAY_NAME} = ?" + val filterArgs = arrayOf(fileName) + + return context.contentResolver.query( + volumeUri, + columnsToGet, + filter, + filterArgs, null, null )?.use { cursor -> val idColumn = cursor.getColumnIndex(MediaStore.Files.FileColumns._ID) @@ -133,7 +134,7 @@ object LocalBackupHelper { val actualFileName = cursor.getString(nameColumn) val modifiedAt = cursor.getLong(modifiedAtColumn) * 1000 log.debug("found backup file with name=$actualFileName modified_at=${modifiedAt.toAbsoluteDateTimeString()}") - modifiedAt to ContentUris.withAppendedId(contentUri, fileId) + modifiedAt to ContentUris.withAppendedId(volumeUri, fileId) } else { log.info("no backup file found for name=$fileName") null @@ -145,10 +146,7 @@ object LocalBackupHelper { return "phoenix-${keyManager.chain.name.lowercase()}-${keyManager.nodeIdHash().take(7)}.bak" } - /** - * Write an encrypted zip file of the payments/channels database, and store it in a public folder of the device. - * Since we're using the media store API for that, no permission is required. - */ + @RequiresApi(Build.VERSION_CODES.Q) suspend fun saveBackupToDisk(context: Context, keyManager: LocalKeyManager) { val encryptedBackup = try { val data = prepareBackupContent(context) @@ -162,10 +160,7 @@ object LocalBackupHelper { } val fileName = getBackupFileName(keyManager) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - saveBackupThroughMediastore(context, encryptedBackup, fileName) - } + saveBackupThroughMediastore(context, encryptedBackup, fileName) } @RequiresApi(Build.VERSION_CODES.Q) @@ -173,11 +168,17 @@ object LocalBackupHelper { log.debug("saving encrypted backup to public dir through mediastore api...") val resolver = context.contentResolver - val uri = getBackupFileUri(context, fileName)?.second ?: createBackupFileUri(context, fileName) + val uri = getBackupFileUri(context, fileName)?.second?.let { + log.debug("found existing backup file") + it + } ?: run { + log.debug("creating new backup file") + createBackupFileUri(context, fileName) + } resolver.openOutputStream(uri, "w")?.use { outputStream -> val array = encryptedBackup.write() outputStream.write(array) - log.debug("encrypted backup successfully saved to public dir ($uri)") + log.debug("encrypted backup successfully saved to public dir ({})", uri) } ?: run { log.error("public backup failed: cannot open output stream for uri=$uri") }