Offer option to recycle backup after restoring

The most common restore scenario is assumed to be moving from one device to another, like when the old one was lost or stolen. Most users probably don't continue to use the old device themselves still.
Since they just restored this backup on their phone, most data is already in this backup. Deduplication allows re-using that, so it doesn't need to be saved again.
This commit is contained in:
Torsten Grote 2024-09-18 15:23:07 -03:00
parent 20ea0b332d
commit 176a703720
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
10 changed files with 203 additions and 0 deletions

View file

@ -53,6 +53,7 @@ class KoinInstrumentationTestApp : App() {
keyManager = get(),
backupManager = get(),
restoreCoordinator = get(),
appBackupManager = get(),
apkRestore = get(),
iconManager = get(),
storageBackup = get(),

View file

@ -9,6 +9,7 @@ import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.MemoryLogger
import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.header.VERSION
import com.stevesoltys.seedvault.settings.SettingsManager
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.delay
@ -112,6 +113,29 @@ internal class AppBackupManager(
}
}
/**
* Returns true if the repo identified by [repoId] can be transferred to this device.
* This is the case when it isn't the same as the current repoId and the version is latest.
*/
fun canRecycleBackupRepo(repoId: String?, version: Byte?): Boolean {
if (repoId == null || version == null) return false
return repoId != crypto.repoId && version == VERSION
}
/**
* Transfers the ownership of the backup repository identified by the [oldRepoId]
* to the current user and device
* by renaming the [TopLevelFolder] of the repo to the current repoId.
*/
@Throws(IOException::class)
suspend fun recycleBackupRepo(oldRepoId: String) {
val newRepoId = crypto.repoId
if (oldRepoId == newRepoId) return
val oldFolder = TopLevelFolder(oldRepoId)
val newFolder = TopLevelFolder(newRepoId)
backendManager.backend.rename(oldFolder, newFolder)
}
/**
* Careful, this removes the entire backup repository from the backend
* and clears local blob cache.

View file

@ -0,0 +1,42 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.restore
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.TextView
import androidx.fragment.app.Fragment
import com.stevesoltys.seedvault.R
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class RecycleBackupFragment : Fragment() {
private val viewModel: RestoreViewModel by sharedViewModel()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
val v: View = inflater.inflate(R.layout.fragment_recycle_backup, container, false)
val backupName = viewModel.chosenRestorableBackup.value?.name
v.requireViewById<TextView>(R.id.descriptionView).text =
getString(R.string.restore_recycle_backup_text, backupName)
v.requireViewById<Button>(R.id.noButton).setOnClickListener {
viewModel.onRecycleBackupFinished(false)
}
v.requireViewById<Button>(R.id.yesButton).setOnClickListener {
viewModel.onRecycleBackupFinished(true)
}
return v
}
}

View file

@ -8,6 +8,7 @@ package com.stevesoltys.seedvault.restore
import android.os.Bundle
import androidx.annotation.CallSuper
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.restore.DisplayFragment.RECYCLE_BACKUP
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES
@ -35,6 +36,7 @@ class RestoreActivity : RequireProvisioningActivity() {
SELECT_APPS -> showFragment(AppSelectionFragment())
RESTORE_APPS -> showFragment(InstallProgressFragment())
RESTORE_BACKUP -> showFragment(RestoreProgressFragment())
RECYCLE_BACKUP -> showFragment(RecycleBackupFragment())
RESTORE_FILES -> showFragment(RestoreFilesFragment())
RESTORE_SELECT_FILES -> showFragment(FilesSelectionFragment(), true)
RESTORE_FILES_STARTED -> {

View file

@ -19,6 +19,7 @@ val restoreUiModule = module {
settingsManager = get(),
keyManager = get(),
backupManager = get(),
appBackupManager = get(),
restoreCoordinator = get(),
apkRestore = get(),
iconManager = get(),

View file

@ -9,6 +9,7 @@ import android.app.Application
import android.app.backup.IBackupManager
import android.content.Intent
import android.graphics.drawable.Drawable
import android.util.Log
import androidx.annotation.UiThread
import androidx.appcompat.content.res.AppCompatResources.getDrawable
import androidx.lifecycle.LiveData
@ -18,6 +19,8 @@ import androidx.lifecycle.viewModelScope
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.repo.AppBackupManager
import com.stevesoltys.seedvault.restore.DisplayFragment.RECYCLE_BACKUP
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES
@ -64,6 +67,7 @@ internal class RestoreViewModel(
keyManager: KeyManager,
backupManager: IBackupManager,
private val restoreCoordinator: RestoreCoordinator,
private val appBackupManager: AppBackupManager,
private val apkRestore: ApkRestore,
private val iconManager: IconManager,
storageBackup: StorageBackup,
@ -174,6 +178,24 @@ internal class RestoreViewModel(
@UiThread
internal fun onFinishClickedAfterRestoringAppData() {
val backup = chosenRestorableBackup.value
if (appBackupManager.canRecycleBackupRepo(backup?.repoId, backup?.version)) {
mDisplayFragment.setEvent(RECYCLE_BACKUP)
} else {
mDisplayFragment.setEvent(RESTORE_FILES)
}
}
@UiThread
internal fun onRecycleBackupFinished(shouldRecycle: Boolean) {
val repoId = chosenRestorableBackup.value?.repoId
if (shouldRecycle && repoId != null) viewModelScope.launch(ioDispatcher) {
try {
appBackupManager.recycleBackupRepo(repoId)
} catch (e: Exception) {
Log.e(TAG, "Error transferring backup repo: ", e)
}
}
mDisplayFragment.setEvent(RESTORE_FILES)
}
@ -218,6 +240,7 @@ internal enum class DisplayFragment {
SELECT_APPS,
RESTORE_APPS,
RESTORE_BACKUP,
RECYCLE_BACKUP,
RESTORE_FILES,
RESTORE_SELECT_FILES,
RESTORE_FILES_STARTED,

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2024 The Calyx Institute
SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?android:attr/textColorSecondary"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M4.03 12.03C3.34 12.71 3 13.53 3 14.5S3.34 16.29 4.03 17C4.71 17.66 5.53 18 6.5 18H13.09C13.04 18.33 13 18.66 13 19C13 19.34 13.04 19.67 13.09 20H6.5C5 20 3.69 19.5 2.61 18.43C1.54 17.38 1 16.09 1 14.58C1 13.28 1.39 12.12 2.17 11.1S4 9.43 5.25 9.15C5.67 7.62 6.5 6.38 7.75 5.43S10.42 4 12 4C13.95 4 15.6 4.68 16.96 6.04C18.32 7.4 19 9.05 19 11C20.15 11.13 21.1 11.63 21.86 12.5C22.37 13.07 22.7 13.71 22.86 14.42C21.82 13.54 20.5 13 19 13C18.89 13 18.79 13 18.68 13C18.62 13 18.56 13 18.5 13H17V11C17 9.62 16.5 8.44 15.54 7.46C14.56 6.5 13.38 6 12 6S9.44 6.5 8.46 7.46C7.5 8.44 7 9.62 7 11H6.5C5.53 11 4.71 11.34 4.03 12.03M23 19L20 16V18H16V20H20V22L23 19Z" />
</vector>

View file

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?><!--
SPDX-FileCopyrightText: 2024 The Calyx Institute
SPDX-License-Identifier: Apache-2.0
-->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/imageView"
style="@style/SudHeaderIcon"
android:importantForAccessibility="no"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_cloud_arrow_right"
app:tint="?android:colorAccent" />
<TextView
android:id="@+id/titleView"
style="@style/SudHeaderTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/restore_recycle_backup_title"
android:textColor="?android:textColorSecondary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/imageView" />
<TextView
android:id="@+id/descriptionView"
style="@style/SudContent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/restore_recycle_backup_text"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/titleView" />
<Button
android:id="@+id/noButton"
style="@style/SudSecondaryButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="40dp"
android:text="@string/restore_recycle_backup_button_no"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/yesButton"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/descriptionView"
app:layout_constraintVertical_bias="1.0" />
<Button
android:id="@+id/yesButton"
style="@style/SudPrimaryButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="40dp"
android:text="@string/restore_recycle_backup_button_yes"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/descriptionView"
app:layout_constraintVertical_bias="1.0" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -216,6 +216,10 @@
<string name="restore_set_empty_result">No suitable backups found at given location.\n\nThis is most likely due to a wrong recovery code or a storage error.</string>
<string name="restore_select_packages">Select apps to restore</string>
<string name="restore_select_packages_all">All of the following apps</string>
<string name="restore_recycle_backup_title">Re-use old backup?</string>
<string name="restore_recycle_backup_text">The ownership of the backup \'%s\' you just restored can be transferred to this device. This will make new backups faster and use less data.\n\nHowever, it will make the backup inaccessible for your old device. If you don\'t use your old device anymore, it is usually fine to re-use the backup.</string>
<string name="restore_recycle_backup_button_no">Don\'t re-use</string>
<string name="restore_recycle_backup_button_yes">Re-use backup</string>
<string name="restore_installing_packages">Re-installing apps</string>
<string name="restore_app_status_installing">Re-installing</string>
<string name="restore_app_status_installed">Re-installed</string>

View file

@ -6,6 +6,7 @@
package com.stevesoltys.seedvault.repo
import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.transport.TransportTest
import io.mockk.Runs
import io.mockk.andThenJust
@ -20,6 +21,7 @@ import org.calyxos.seedvault.core.backends.AppBackupFileType.Snapshot
import org.calyxos.seedvault.core.backends.Backend
import org.calyxos.seedvault.core.backends.FileInfo
import org.calyxos.seedvault.core.backends.TopLevelFolder
import org.calyxos.seedvault.core.toHexString
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
@ -123,6 +125,27 @@ internal class AppBackupManagerTest : TransportTest() {
assertEquals(snapshot, appBackupManager.afterBackupFinished(true))
}
@Test
fun `recycleBackupRepo doesn't do anything if repoId is current`() = runBlocking {
every { crypto.repoId } returns repoId
appBackupManager.recycleBackupRepo(repoId)
}
@Test
fun `recycleBackupRepo renames different repo`() = runBlocking {
val oldRepoId = getRandomByteArray(32).toHexString()
every { crypto.repoId } returns repoId
every { backendManager.backend } returns backend
coEvery { backend.rename(TopLevelFolder(oldRepoId), TopLevelFolder(repoId)) } just Runs
appBackupManager.recycleBackupRepo(oldRepoId)
coVerify {
backend.rename(TopLevelFolder(oldRepoId), TopLevelFolder(repoId))
}
}
@Test
fun `removeBackupRepo deletes repo and local cache`() = runBlocking {
every { blobCache.clearLocalCache() } just Runs