From bdefb04a0d3fdff76c649be756eb1a90370e6ca9 Mon Sep 17 00:00:00 2001
From: Torsten Grote <t@grobox.de>
Date: Mon, 22 Feb 2021 11:26:21 -0300
Subject: [PATCH] Add UI for restoring files after app restore

---
 app/src/main/AndroidManifest.xml              |  6 ++
 .../java/com/stevesoltys/seedvault/App.kt     |  2 +-
 .../seedvault/restore/RestoreActivity.kt      |  4 ++
 .../seedvault/restore/RestoreFilesFragment.kt | 66 +++++++++++++++++++
 .../restore/RestoreProgressFragment.kt        |  6 +-
 .../seedvault/restore/RestoreViewModel.kt     | 34 +++++++++-
 .../install/InstallProgressFragment.kt        |  4 +-
 .../stevesoltys/seedvault/storage/Services.kt | 12 ++++
 app/src/main/res/layout/footer_snapshots.xml  | 18 +++++
 .../layout/fragment_restore_files_started.xml | 56 ++++++++++++++++
 app/src/main/res/layout/header_snapshots.xml  | 33 ++++++++++
 app/src/main/res/values/strings.xml           |  7 ++
 12 files changed, 238 insertions(+), 10 deletions(-)
 create mode 100644 app/src/main/java/com/stevesoltys/seedvault/restore/RestoreFilesFragment.kt
 create mode 100644 app/src/main/res/layout/footer_snapshots.xml
 create mode 100644 app/src/main/res/layout/fragment_restore_files_started.xml
 create mode 100644 app/src/main/res/layout/header_snapshots.xml

diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7b8504f9..991ac6cc 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -125,6 +125,12 @@
             android:exported="false"
             android:foregroundServiceType="dataSync"
             android:label="BackupService" />
+        <!-- Does restore as a foreground service -->
+        <service
+            android:name=".storage.StorageRestoreService"
+            android:exported="false"
+            android:foregroundServiceType="dataSync"
+            android:label="RestoreService" />
 
     </application>
 </manifest>
diff --git a/app/src/main/java/com/stevesoltys/seedvault/App.kt b/app/src/main/java/com/stevesoltys/seedvault/App.kt
index eafcf5c8..b2d3e0e5 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/App.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/App.kt
@@ -49,7 +49,7 @@ open class App : Application() {
         viewModel { RecoveryCodeViewModel(this@App, get(), get(), get()) }
         viewModel { BackupStorageViewModel(this@App, get(), get(), get()) }
         viewModel { RestoreStorageViewModel(this@App, get(), get()) }
-        viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get()) }
+        viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get(), get()) }
         viewModel { FileSelectionViewModel(this@App, get()) }
     }
 
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreActivity.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreActivity.kt
index 635d10d7..9b78d7d3 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreActivity.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreActivity.kt
@@ -5,6 +5,8 @@ import androidx.annotation.CallSuper
 import com.stevesoltys.seedvault.R
 import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
 import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
+import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES
+import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES_STARTED
 import com.stevesoltys.seedvault.restore.install.InstallProgressFragment
 import com.stevesoltys.seedvault.ui.LiveEventHandler
 import com.stevesoltys.seedvault.ui.RequireProvisioningActivity
@@ -28,6 +30,8 @@ class RestoreActivity : RequireProvisioningActivity() {
             when (fragment) {
                 RESTORE_APPS -> showFragment(InstallProgressFragment())
                 RESTORE_BACKUP -> showFragment(RestoreProgressFragment())
+                RESTORE_FILES -> showFragment(RestoreFilesFragment())
+                RESTORE_FILES_STARTED -> showFragment(RestoreFilesStartedFragment())
                 else -> throw AssertionError()
             }
         })
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreFilesFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreFilesFragment.kt
new file mode 100644
index 00000000..235ce9ab
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreFilesFragment.kt
@@ -0,0 +1,66 @@
+package com.stevesoltys.seedvault.restore
+
+import android.app.Activity.RESULT_OK
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewStub
+import android.widget.Button
+import android.widget.TextView
+import androidx.fragment.app.Fragment
+import com.stevesoltys.seedvault.R
+import org.calyxos.backup.storage.api.SnapshotItem
+import org.calyxos.backup.storage.ui.restore.SnapshotFragment
+import org.koin.androidx.viewmodel.ext.android.sharedViewModel
+
+internal class RestoreFilesFragment : SnapshotFragment() {
+    override val viewModel: RestoreViewModel by sharedViewModel()
+
+    override fun onCreateView(
+        inflater: LayoutInflater,
+        container: ViewGroup?,
+        savedInstanceState: Bundle?
+    ): View {
+        val v = super.onCreateView(inflater, container, savedInstanceState)
+
+        val topStub: ViewStub = v.findViewById(R.id.topStub)
+        topStub.layoutResource = R.layout.header_snapshots
+        topStub.inflate()
+
+        val bottomStub: ViewStub = v.findViewById(R.id.bottomStub)
+        bottomStub.layoutResource = R.layout.footer_snapshots
+        val footer = bottomStub.inflate()
+        val skipView: TextView = footer.findViewById(R.id.skipView)
+        skipView.setOnClickListener {
+            requireActivity().apply {
+                setResult(RESULT_OK)
+                finishAfterTransition()
+            }
+        }
+        return v
+    }
+
+    override fun onSnapshotClicked(item: SnapshotItem) {
+        viewModel.startFilesRestore(item)
+    }
+}
+
+internal class RestoreFilesStartedFragment : Fragment() {
+    override fun onCreateView(
+        inflater: LayoutInflater,
+        container: ViewGroup?,
+        savedInstanceState: Bundle?
+    ): View {
+        val v: View = inflater.inflate(R.layout.fragment_restore_files_started, container, false)
+
+        val button: Button = v.findViewById(R.id.button)
+        button.setOnClickListener {
+            requireActivity().apply {
+                setResult(RESULT_OK)
+                finishAfterTransition()
+            }
+        }
+        return v
+    }
+}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressFragment.kt
index e3b4ce77..adc8ced4 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressFragment.kt
@@ -1,6 +1,5 @@
 package com.stevesoltys.seedvault.restore
 
-import android.app.Activity.RESULT_OK
 import android.os.Bundle
 import android.view.LayoutInflater
 import android.view.View
@@ -38,7 +37,7 @@ class RestoreProgressFragment : Fragment() {
         inflater: LayoutInflater,
         container: ViewGroup?,
         savedInstanceState: Bundle?
-    ): View? {
+    ): View {
         val v: View = inflater.inflate(R.layout.fragment_restore_progress, container, false)
 
         progressBar = v.findViewById(R.id.progressBar)
@@ -61,8 +60,7 @@ class RestoreProgressFragment : Fragment() {
 
         button.setText(R.string.restore_finished_button)
         button.setOnClickListener {
-            requireActivity().setResult(RESULT_OK)
-            requireActivity().finishAfterTransition()
+            viewModel.onFinishClickedAfterRestoringAppData()
         }
 
         // decryption will fail when the device is locked, so keep the screen on to prevent locking
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt
index 199de755..6fb3dea8 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt
@@ -5,9 +5,11 @@ import android.app.backup.IBackupManager
 import android.app.backup.IRestoreObserver
 import android.app.backup.IRestoreSession
 import android.app.backup.RestoreSet
+import android.content.Intent
 import android.os.RemoteException
 import android.os.UserHandle
 import android.util.Log
+import androidx.annotation.UiThread
 import androidx.annotation.WorkerThread
 import androidx.lifecycle.LiveData
 import androidx.lifecycle.MutableLiveData
@@ -27,11 +29,14 @@ import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
 import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
 import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
 import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
+import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES
+import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES_STARTED
 import com.stevesoltys.seedvault.restore.install.ApkRestore
 import com.stevesoltys.seedvault.restore.install.InstallIntentCreator
 import com.stevesoltys.seedvault.restore.install.InstallResult
 import com.stevesoltys.seedvault.restore.install.isInstalled
 import com.stevesoltys.seedvault.settings.SettingsManager
+import com.stevesoltys.seedvault.storage.StorageRestoreService
 import com.stevesoltys.seedvault.transport.TRANSPORT_ID
 import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
 import com.stevesoltys.seedvault.ui.AppBackupState
@@ -54,6 +59,10 @@ import kotlinx.coroutines.flow.flowOn
 import kotlinx.coroutines.flow.onCompletion
 import kotlinx.coroutines.flow.onStart
 import kotlinx.coroutines.launch
+import org.calyxos.backup.storage.api.SnapshotItem
+import org.calyxos.backup.storage.api.StorageBackup
+import org.calyxos.backup.storage.restore.RestoreService.Companion.EXTRA_TIMESTAMP_START
+import org.calyxos.backup.storage.ui.restore.SnapshotViewModel
 import java.util.LinkedList
 import kotlin.coroutines.Continuation
 import kotlin.coroutines.resume
@@ -68,8 +77,10 @@ internal class RestoreViewModel(
     private val backupManager: IBackupManager,
     private val restoreCoordinator: RestoreCoordinator,
     private val apkRestore: ApkRestore,
+    storageBackup: StorageBackup,
     private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
-) : RequireProvisioningViewModel(app, settingsManager, keyManager), RestorableBackupClickListener {
+) : RequireProvisioningViewModel(app, settingsManager, keyManager),
+    RestorableBackupClickListener, SnapshotViewModel {
 
     override val isRestoreOperation = true
 
@@ -110,6 +121,8 @@ internal class RestoreViewModel(
     private val mRestoreBackupResult = MutableLiveData<RestoreBackupResult>()
     internal val restoreBackupResult: LiveData<RestoreBackupResult> get() = mRestoreBackupResult
 
+    override val snapshots = storageBackup.getBackupSnapshots().asLiveData(ioDispatcher)
+
     @Throws(RemoteException::class)
     private fun getOrStartSession(): IRestoreSession {
         val session = this.session
@@ -168,7 +181,7 @@ internal class RestoreViewModel(
             .asLiveData(ioDispatcher)
     }
 
-    internal fun onNextClicked() {
+    internal fun onNextClickedAfterInstallingApps() {
         mDisplayFragment.postEvent(RESTORE_BACKUP)
         val token = mChosenRestorableBackup.value?.token ?: throw AssertionError()
         viewModelScope.launch(ioDispatcher) {
@@ -371,6 +384,19 @@ internal class RestoreViewModel(
 
     }
 
+    @UiThread
+    internal fun onFinishClickedAfterRestoringAppData() {
+        mDisplayFragment.setEvent(RESTORE_FILES)
+    }
+
+    @UiThread
+    internal fun startFilesRestore(item: SnapshotItem) {
+        val i = Intent(app, StorageRestoreService::class.java)
+        i.putExtra(EXTRA_TIMESTAMP_START, item.time)
+        app.startForegroundService(i)
+        mDisplayFragment.setEvent(RESTORE_FILES_STARTED)
+    }
+
 }
 
 internal class RestoreSetResult(
@@ -389,4 +415,6 @@ internal class RestoreBackupResult(val errorMsg: String? = null) {
     internal fun hasError(): Boolean = errorMsg != null
 }
 
-internal enum class DisplayFragment { RESTORE_APPS, RESTORE_BACKUP }
+internal enum class DisplayFragment {
+    RESTORE_APPS, RESTORE_BACKUP, RESTORE_FILES, RESTORE_FILES_STARTED
+}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallProgressFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallProgressFragment.kt
index 9388c7e2..a4c2c813 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallProgressFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallProgressFragment.kt
@@ -59,7 +59,7 @@ class InstallProgressFragment : Fragment(), InstallItemListener {
             addItemDecoration(DividerItemDecoration(context, VERTICAL))
         }
         button.setText(R.string.restore_next)
-        button.setOnClickListener { viewModel.onNextClicked() }
+        button.setOnClickListener { viewModel.onNextClickedAfterInstallingApps() }
 
         viewModel.chosenRestorableBackup.observe(viewLifecycleOwner, Observer { restorableBackup ->
             backupNameView.text = restorableBackup.name
@@ -76,7 +76,7 @@ class InstallProgressFragment : Fragment(), InstallItemListener {
 
     private fun onInstallResult(installResult: InstallResult) {
         // skip this screen, if there are no apps to install
-        if (installResult.isEmpty) viewModel.onNextClicked()
+        if (installResult.isEmpty) viewModel.onNextClickedAfterInstallingApps()
 
         // if finished, treat all still queued apps as failed and resort/redisplay adapter items
         if (installResult.isFinished) {
diff --git a/app/src/main/java/com/stevesoltys/seedvault/storage/Services.kt b/app/src/main/java/com/stevesoltys/seedvault/storage/Services.kt
index 30b82e28..5f55caec 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/storage/Services.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/storage/Services.kt
@@ -1,10 +1,13 @@
 package com.stevesoltys.seedvault.storage
 
 import org.calyxos.backup.storage.api.BackupObserver
+import org.calyxos.backup.storage.api.RestoreObserver
 import org.calyxos.backup.storage.api.StorageBackup
 import org.calyxos.backup.storage.backup.BackupJobService
 import org.calyxos.backup.storage.backup.BackupService
 import org.calyxos.backup.storage.backup.NotificationBackupObserver
+import org.calyxos.backup.storage.restore.NotificationRestoreObserver
+import org.calyxos.backup.storage.restore.RestoreService
 import org.koin.android.ext.android.inject
 
 /*
@@ -28,3 +31,12 @@ internal class StorageBackupService : BackupService() {
         NotificationBackupObserver(applicationContext)
     }
 }
+
+internal class StorageRestoreService : RestoreService() {
+    override val storageBackup: StorageBackup by inject()
+
+    // use lazy delegate because context isn't available during construction time
+    override val restoreObserver: RestoreObserver by lazy {
+        NotificationRestoreObserver(applicationContext)
+    }
+}
diff --git a/app/src/main/res/layout/footer_snapshots.xml b/app/src/main/res/layout/footer_snapshots.xml
new file mode 100644
index 00000000..9b4e4d84
--- /dev/null
+++ b/app/src/main/res/layout/footer_snapshots.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<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="wrap_content">
+
+    <TextView
+        android:id="@+id/skipView"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_margin="16dp"
+        android:text="@string/restore_storage_skip"
+        android:textColor="?android:colorAccent"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/app/src/main/res/layout/fragment_restore_files_started.xml b/app/src/main/res/layout/fragment_restore_files_started.xml
new file mode 100644
index 00000000..8746717b
--- /dev/null
+++ b/app/src/main/res/layout/fragment_restore_files_started.xml
@@ -0,0 +1,56 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <ImageView
+        android:id="@+id/imageView"
+        android:layout_width="32dp"
+        android:layout_height="32dp"
+        android:layout_margin="16dp"
+        android:tint="?android:colorAccent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        app:srcCompat="@drawable/ic_cloud_restore"
+        tools:ignore="ContentDescription" />
+
+    <TextView
+        android:id="@+id/titleView"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_margin="16dp"
+        android:gravity="center"
+        android:text="@string/restore_storage_in_progress_title"
+        android:textSize="24sp"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/imageView" />
+
+    <TextView
+        android:id="@+id/infoView"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_margin="16dp"
+        android:text="@string/restore_storage_in_progress_info"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/titleView" />
+
+    <Button
+        android:id="@+id/button"
+        style="@style/Widget.AppCompat.Button.Colored"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_margin="16dp"
+        android:text="@string/restore_storage_got_it"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintHorizontal_bias="1.0"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/infoView"
+        app:layout_constraintVertical_bias="1.0" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/app/src/main/res/layout/header_snapshots.xml b/app/src/main/res/layout/header_snapshots.xml
new file mode 100644
index 00000000..d91bfa44
--- /dev/null
+++ b/app/src/main/res/layout/header_snapshots.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content">
+
+    <ImageView
+        android:id="@+id/imageView"
+        android:layout_width="32dp"
+        android:layout_height="32dp"
+        android:layout_margin="16dp"
+        android:tint="?android:colorAccent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        app:srcCompat="@drawable/ic_cloud_download"
+        tools:ignore="ContentDescription" />
+
+    <TextView
+        android:id="@+id/titleView"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_margin="16dp"
+        android:gravity="center"
+        android:text="@string/restore_storage_choose_snapshot"
+        android:textSize="24sp"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/imageView" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 7af0fd91..d6335f55 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -152,6 +152,13 @@
     <string name="restore_finished_success">Restore complete</string>
     <string name="restore_finished_error">An error occurred while restoring the backup.</string>
     <string name="restore_finished_button">Finish</string>
+
+    <string name="restore_storage_skip">Skip restoring files</string>
+    <string name="restore_storage_choose_snapshot">Choose a storage backup to restore (experimental)</string>
+    <string name="restore_storage_in_progress_title">Files are being restored…</string>
+    <string name="restore_storage_in_progress_info">Your files are being restored in the background. You can start using your phone while this is running.\n\nSome apps (e.g. Signal or WhatsApp) might require files to be fully restored to import a backup. Try to avoid starting those apps before file restore is not complete.</string>
+    <string name="restore_storage_got_it">Got it</string>
+
     <string name="storage_internal_warning_title">Warning</string>
     <string name="storage_internal_warning_message">You have chosen internal storage for your backup. This will not be available when your phone is lost or broken.</string>
     <string name="storage_internal_warning_choose_other">Choose other</string>