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 ->