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>