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:
parent
20ea0b332d
commit
176a703720
10 changed files with 203 additions and 0 deletions
|
@ -53,6 +53,7 @@ class KoinInstrumentationTestApp : App() {
|
|||
keyManager = get(),
|
||||
backupManager = get(),
|
||||
restoreCoordinator = get(),
|
||||
appBackupManager = get(),
|
||||
apkRestore = get(),
|
||||
iconManager = get(),
|
||||
storageBackup = get(),
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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 -> {
|
||||
|
|
|
@ -19,6 +19,7 @@ val restoreUiModule = module {
|
|||
settingsManager = get(),
|
||||
keyManager = get(),
|
||||
backupManager = get(),
|
||||
appBackupManager = get(),
|
||||
restoreCoordinator = get(),
|
||||
apkRestore = get(),
|
||||
iconManager = get(),
|
||||
|
|
|
@ -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,
|
||||
|
|
16
app/src/main/res/drawable/ic_cloud_arrow_right.xml
Normal file
16
app/src/main/res/drawable/ic_cloud_arrow_right.xml
Normal 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>
|
||||
|
67
app/src/main/res/layout/fragment_recycle_backup.xml
Normal file
67
app/src/main/res/layout/fragment_recycle_backup.xml
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue