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 e2e5a47fb..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 @@ -67,9 +67,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 @@ -98,6 +98,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 @@ -229,10 +231,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) { @@ -535,6 +537,9 @@ fun AppView( val userPrefs = userPrefs val exchangeRates = fiatRates lastCompletedPayment?.let { payment -> + 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/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..b5324151a --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/InitViewModel.kt @@ -0,0 +1,94 @@ +/* + * 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() + } + } + } +} \ 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..d3335a874 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/RestorePaymentsBackupView.kt @@ -0,0 +1,143 @@ +/* + * 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.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 +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 = 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) + } 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.") + } + + Spacer(modifier = Modifier.height(32.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..fd1f5d177 --- /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.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, + ) + } +} 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..3ba440d58 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/initwallet/restore/RestoreWalletViewModel.kt @@ -0,0 +1,184 @@ +/* + * 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 android.os.Build +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 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + LocalBackupHelper.cleanUpOldBackupFile(context, keyManager, encryptedBackup, uri) + log.info("old backup files cleaned up") + } + 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..5603bc46d --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/LocalBackupWorker.kt @@ -0,0 +1,92 @@ +/* + * 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 android.os.Build +import androidx.annotation.RequiresApi +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 + +/** + * 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 { + // 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 + 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 $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 $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 $name once") + val work = OneTimeWorkRequestBuilder().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 95637f153..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,6 +233,9 @@ class NodeService : Service() { doStartBusiness(decryptedMnemonics, requestCheckLegacyChannels) ChannelsWatcher.schedule(applicationContext) DailyConnect.schedule(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/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 new file mode 100644 index 000000000..cf13e839f --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/backup/LocalBackupHelper.kt @@ -0,0 +1,325 @@ +/* + * 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.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 +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.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 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 { + + 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" + + @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...") + 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.debug("zipping channels db...") + FileInputStream(channelsDbFile).use { fis -> + zos.putNextEntry(ZipEntry(channelsDbFile.name)) + zos.write(fis.readBytes()) + } + log.debug("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(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 columnsToGet = arrayOf( + MediaStore.Files.FileColumns._ID, + MediaStore.Files.FileColumns.DISPLAY_NAME, + MediaStore.Files.FileColumns.DATE_MODIFIED, + ) + 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) + 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.debug("found backup file with name=$actualFileName modified_at=${modifiedAt.toAbsoluteDateTimeString()}") + modifiedAt to ContentUris.withAppendedId(volumeUri, 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" + } + + @RequiresApi(Build.VERSION_CODES.Q) + 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) + 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?.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) + } ?: 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 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 -> + 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-legacy/src/main/AndroidManifest.xml b/phoenix-legacy/src/main/AndroidManifest.xml index 7a3f56bfa..8acc0f0c7 100644 --- a/phoenix-legacy/src/main/AndroidManifest.xml +++ b/phoenix-legacy/src/main/AndroidManifest.xml @@ -26,7 +26,6 @@ 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 ) val cloudKitDb = makeCloudKitDb(appDb, paymentsDb) @@ -81,10 +80,25 @@ class DatabaseManager( return db.payments } + suspend fun channelsDb(): SqliteChannelsDb { + val db = databases.filterNotNull().first() + return db.channels as SqliteChannelsDb + } + suspend fun cloudKitDb(): CloudKitInterface? { val db = databases.filterNotNull().first() return db.cloudKit } + + 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" + } + } } data class PhoenixDatabases( 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,