When restoring, ask for location first and then restore code

This also checks if there's a backup at the chosen location
and requires the user to select another once,
if we can not find a backup.
This commit is contained in:
Torsten Grote 2019-09-11 17:26:03 -03:00
parent af43c6154d
commit 9cede639f3
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
9 changed files with 180 additions and 112 deletions

View file

@ -1,10 +1,14 @@
package com.stevesoltys.backup.restore package com.stevesoltys.backup.restore
import android.os.Bundle import android.os.Bundle
import androidx.annotation.CallSuper
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders import androidx.lifecycle.ViewModelProviders
import com.stevesoltys.backup.R import com.stevesoltys.backup.R
import com.stevesoltys.backup.transport.backup.plugins.DIRECTORY_ROOT
import com.stevesoltys.backup.ui.BackupActivity import com.stevesoltys.backup.ui.BackupActivity
import com.stevesoltys.backup.ui.BackupLocationFragment
import com.stevesoltys.backup.ui.BackupViewModel import com.stevesoltys.backup.ui.BackupViewModel
class RestoreActivity : BackupActivity() { class RestoreActivity : BackupActivity() {
@ -15,8 +19,6 @@ class RestoreActivity : BackupActivity() {
override fun getInitialFragment() = RestoreSetFragment() override fun getInitialFragment() = RestoreSetFragment()
override fun isRestoreOperation() = true
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
viewModel = ViewModelProviders.of(this).get(RestoreViewModel::class.java) viewModel = ViewModelProviders.of(this).get(RestoreViewModel::class.java)
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -32,8 +34,25 @@ class RestoreActivity : BackupActivity() {
} }
} }
@CallSuper
override fun onStart() {
super.onStart()
if (isFinishing) return
// check that backup is provisioned
if (!viewModel.validLocationIsSet()) {
showFragment(BackupLocationFragment())
} else if (!viewModel.recoveryCodeIsSet()) {
showRecoveryCodeActivity()
}
}
override fun onInvalidLocation() { override fun onInvalidLocation() {
// TODO alert dialog? AlertDialog.Builder(this)
.setTitle(getString(R.string.restore_invalid_location_title))
.setMessage(getString(R.string.restore_invalid_location_message, DIRECTORY_ROOT))
.setPositiveButton(android.R.string.ok) { dialog, _ -> dialog.dismiss() }
.show()
} }
} }

View file

@ -4,16 +4,23 @@ import android.app.Application
import android.app.backup.IRestoreObserver import android.app.backup.IRestoreObserver
import android.app.backup.IRestoreSession import android.app.backup.IRestoreSession
import android.app.backup.RestoreSet import android.app.backup.RestoreSet
import android.content.Intent
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import com.stevesoltys.backup.Backup import com.stevesoltys.backup.Backup
import com.stevesoltys.backup.R import com.stevesoltys.backup.R
import com.stevesoltys.backup.session.backup.BackupMonitor import com.stevesoltys.backup.session.backup.BackupMonitor
import com.stevesoltys.backup.settings.setBackupFolderUri
import com.stevesoltys.backup.transport.ConfigurableBackupTransportService
import com.stevesoltys.backup.transport.TRANSPORT_ID import com.stevesoltys.backup.transport.TRANSPORT_ID
import com.stevesoltys.backup.transport.backup.plugins.DIRECTORY_ROOT
import com.stevesoltys.backup.transport.restore.plugins.DocumentsProviderRestorePlugin.Companion.getBackups
import com.stevesoltys.backup.ui.BackupViewModel import com.stevesoltys.backup.ui.BackupViewModel
import com.stevesoltys.backup.ui.LocationResult
private val TAG = RestoreViewModel::class.java.simpleName private val TAG = RestoreViewModel::class.java.simpleName
@ -21,6 +28,8 @@ class RestoreViewModel(app: Application) : BackupViewModel(app), RestoreSetClick
private val backupManager = Backup.backupManager private val backupManager = Backup.backupManager
override val isRestoreOperation = true
private var session: IRestoreSession? = null private var session: IRestoreSession? = null
private var observer: RestoreObserver? = null private var observer: RestoreObserver? = null
private val monitor = BackupMonitor() private val monitor = BackupMonitor()
@ -41,9 +50,39 @@ class RestoreViewModel(app: Application) : BackupViewModel(app), RestoreSetClick
// Zero on success; a nonzero error code if the restore operation as a whole failed. // Zero on success; a nonzero error code if the restore operation as a whole failed.
internal val restoreFinished: LiveData<Int> get() = mRestoreFinished internal val restoreFinished: LiveData<Int> get() = mRestoreFinished
override fun acceptBackupLocation(folderUri: Uri): Boolean { override fun onLocationSet(folderUri: Uri, isInitialSetup: Boolean) {
// TODO search if there's really a backup available in this location and see if we can decrypt it if (hasBackup(folderUri)) {
return true // store backup folder location in settings
setBackupFolderUri(app, folderUri)
// stop backup service to be sure the old location will get updated
app.stopService(Intent(app, ConfigurableBackupTransportService::class.java))
Log.d(TAG, "New storage location chosen: $folderUri")
mLocationSet.setEvent(LocationResult(false, isInitialSetup))
} else {
Log.w(TAG, "Location was rejected: $folderUri")
// notify the UI that the location was invalid
mLocationSet.setEvent(LocationResult(false, isInitialSetup))
}
}
/**
* Searches if there's really a backup available in the given location.
* Returns true if at least one was found and false otherwise.
*
* This method is not plugin-agnostic and breaks encapsulation.
* It is specific to the (currently only) DocumentsProvider plugin.
*
* TODO maybe move this to the RestoreCoordinator once we can inject it
*/
private fun hasBackup(folderUri: Uri): Boolean {
val parent = DocumentFile.fromTreeUri(app, folderUri) ?: throw AssertionError()
val rootDir = parent.findFile(DIRECTORY_ROOT) ?: return false
val backupSets = getBackups(rootDir)
return backupSets.isNotEmpty()
} }
internal fun loadRestoreSets() { internal fun loadRestoreSets() {

View file

@ -1,9 +1,12 @@
package com.stevesoltys.backup.settings package com.stevesoltys.backup.settings
import android.os.Bundle import android.os.Bundle
import androidx.annotation.CallSuper
import androidx.lifecycle.ViewModelProviders import androidx.lifecycle.ViewModelProviders
import com.stevesoltys.backup.Backup
import com.stevesoltys.backup.R import com.stevesoltys.backup.R
import com.stevesoltys.backup.ui.BackupActivity import com.stevesoltys.backup.ui.BackupActivity
import com.stevesoltys.backup.ui.BackupLocationFragment
import com.stevesoltys.backup.ui.BackupViewModel import com.stevesoltys.backup.ui.BackupViewModel
class SettingsActivity : BackupActivity() { class SettingsActivity : BackupActivity() {
@ -14,8 +17,6 @@ class SettingsActivity : BackupActivity() {
override fun getInitialFragment() = SettingsFragment() override fun getInitialFragment() = SettingsFragment()
override fun isRestoreOperation() = false
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
viewModel = ViewModelProviders.of(this).get(SettingsViewModel::class.java) viewModel = ViewModelProviders.of(this).get(SettingsViewModel::class.java)
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -27,4 +28,19 @@ class SettingsActivity : BackupActivity() {
if (savedInstanceState == null) showFragment(getInitialFragment()) if (savedInstanceState == null) showFragment(getInitialFragment())
} }
@CallSuper
override fun onStart() {
super.onStart()
if (isFinishing) return
// check that backup is provisioned
if (!viewModel.recoveryCodeIsSet()) {
showRecoveryCodeActivity()
} else if (!viewModel.validLocationIsSet()) {
showFragment(BackupLocationFragment())
// remove potential error notifications
(application as Backup).notificationManager.onBackupErrorSeen()
}
}
} }

View file

@ -1,11 +1,61 @@
package com.stevesoltys.backup.settings package com.stevesoltys.backup.settings
import android.app.Application import android.app.Application
import android.app.backup.BackupProgress
import android.app.backup.IBackupObserver
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.annotation.WorkerThread
import com.stevesoltys.backup.Backup
import com.stevesoltys.backup.transport.ConfigurableBackupTransportService
import com.stevesoltys.backup.transport.TRANSPORT_ID
import com.stevesoltys.backup.transport.requestBackup import com.stevesoltys.backup.transport.requestBackup
import com.stevesoltys.backup.ui.BackupViewModel import com.stevesoltys.backup.ui.BackupViewModel
import com.stevesoltys.backup.ui.LocationResult
private val TAG = SettingsViewModel::class.java.simpleName
class SettingsViewModel(app: Application) : BackupViewModel(app) { class SettingsViewModel(app: Application) : BackupViewModel(app) {
override val isRestoreOperation = false
fun backupNow() = Thread { requestBackup(app) }.start() fun backupNow() = Thread { requestBackup(app) }.start()
override fun onLocationSet(folderUri: Uri, isInitialSetup: Boolean) {
// store backup folder location in settings
setBackupFolderUri(app, folderUri)
// stop backup service to be sure the old location will get updated
app.stopService(Intent(app, ConfigurableBackupTransportService::class.java))
Log.d(TAG, "New storage location chosen: $folderUri")
// initialize the new location
val observer = InitializationObserver(isInitialSetup)
Backup.backupManager.initializeTransports(arrayOf(TRANSPORT_ID), observer)
}
@WorkerThread
private inner class InitializationObserver(private val initialSetUp: Boolean) : IBackupObserver.Stub() {
override fun onUpdate(currentBackupPackage: String, backupProgress: BackupProgress) {
// noop
}
override fun onResult(target: String, status: Int) {
// noop
}
override fun backupFinished(status: Int) {
if (Log.isLoggable(TAG, Log.INFO)) {
Log.i(TAG, "Initialization finished. Status: $status")
}
if (status == 0) {
// notify the UI that the location has been set
mLocationSet.postEvent(LocationResult(true, initialSetUp))
} else {
// notify the UI that the location was invalid
mLocationSet.postEvent(LocationResult(false, initialSetUp))
}
}
}
} }

View file

@ -10,11 +10,11 @@ import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
const val DIRECTORY_ROOT = ".AndroidBackup"
const val DIRECTORY_FULL_BACKUP = "full" const val DIRECTORY_FULL_BACKUP = "full"
const val DIRECTORY_KEY_VALUE_BACKUP = "kv" const val DIRECTORY_KEY_VALUE_BACKUP = "kv"
const val FILE_BACKUP_METADATA = ".backup.metadata" const val FILE_BACKUP_METADATA = ".backup.metadata"
const val FILE_NO_MEDIA = ".nomedia" const val FILE_NO_MEDIA = ".nomedia"
private const val ROOT_DIR_NAME = ".AndroidBackup"
private const val MIME_TYPE = "application/octet-stream" private const val MIME_TYPE = "application/octet-stream"
private val TAG = DocumentsStorage::class.java.simpleName private val TAG = DocumentsStorage::class.java.simpleName
@ -26,7 +26,7 @@ class DocumentsStorage(private val context: Context, parentFolder: Uri?, token:
// [fromTreeUri] should only return null when SDK_INT < 21 // [fromTreeUri] should only return null when SDK_INT < 21
val parent = DocumentFile.fromTreeUri(context, folderUri) ?: throw AssertionError() val parent = DocumentFile.fromTreeUri(context, folderUri) ?: throw AssertionError()
try { try {
val rootDir = parent.createOrGetDirectory(ROOT_DIR_NAME) val rootDir = parent.createOrGetDirectory(DIRECTORY_ROOT)
// create .nomedia file to prevent Android's MediaScanner from trying to index the backup // create .nomedia file to prevent Android's MediaScanner from trying to index the backup
rootDir.createOrGetFile(FILE_NO_MEDIA) rootDir.createOrGetFile(FILE_NO_MEDIA)
rootDir rootDir

View file

@ -25,7 +25,24 @@ class DocumentsProviderRestorePlugin(private val storage: DocumentsStorage) : Re
override fun getAvailableBackups(): Sequence<EncryptedBackupMetadata>? { override fun getAvailableBackups(): Sequence<EncryptedBackupMetadata>? {
val rootDir = storage.rootBackupDir ?: return null val rootDir = storage.rootBackupDir ?: return null
val files = ArrayList<Pair<Long, DocumentFile>>() val backupSets = getBackups(rootDir)
val iterator = backupSets.iterator()
return generateSequence {
if (!iterator.hasNext()) return@generateSequence null // end sequence
val backupSet = iterator.next()
try {
val stream = storage.getInputStream(backupSet.metadataFile)
EncryptedBackupMetadata(backupSet.token, stream)
} catch (e: IOException) {
Log.e(TAG, "Error getting InputStream for backup metadata.", e)
EncryptedBackupMetadata(backupSet.token)
}
}
}
companion object {
fun getBackups(rootDir: DocumentFile): List<BackupSet> {
val backupSets = ArrayList<BackupSet>()
for (set in rootDir.listFiles()) { for (set in rootDir.listFiles()) {
if (!set.isDirectory || set.name == null) { if (!set.isDirectory || set.name == null) {
if (set.name != FILE_NO_MEDIA) { if (set.name != FILE_NO_MEDIA) {
@ -43,23 +60,13 @@ class DocumentsProviderRestorePlugin(private val storage: DocumentsStorage) : Re
if (metadata == null) { if (metadata == null) {
Log.w(TAG, "Missing metadata file in backup set folder: ${set.name}") Log.w(TAG, "Missing metadata file in backup set folder: ${set.name}")
} else { } else {
files.add(Pair(token, metadata)) backupSets.add(BackupSet(token, metadata))
} }
} }
val iterator = files.iterator() return backupSets
return generateSequence {
if (!iterator.hasNext()) return@generateSequence null // end sequence
val pair = iterator.next()
val token = pair.first
val metadata = pair.second
try {
val stream = storage.getInputStream(metadata)
EncryptedBackupMetadata(token, stream)
} catch (e: IOException) {
Log.e(TAG, "Error getting InputStream for backup metadata.", e)
EncryptedBackupMetadata(token)
}
} }
} }
} }
class BackupSet(val token: Long, val metadataFile: DocumentFile)

View file

@ -9,7 +9,6 @@ import android.widget.Toast.LENGTH_LONG
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.stevesoltys.backup.Backup
import com.stevesoltys.backup.R import com.stevesoltys.backup.R
const val REQUEST_CODE_OPEN_DOCUMENT_TREE = 1 const val REQUEST_CODE_OPEN_DOCUMENT_TREE = 1
@ -27,13 +26,11 @@ abstract class BackupActivity : AppCompatActivity() {
protected abstract fun getInitialFragment(): Fragment protected abstract fun getInitialFragment(): Fragment
protected abstract fun isRestoreOperation(): Boolean
@CallSuper @CallSuper
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
getViewModel().onLocationSet.observeEvent(this, LiveEventHandler { result -> getViewModel().locationSet.observeEvent(this, LiveEventHandler { result ->
if (result.validLocation) { if (result.validLocation) {
if (result.initialSetup) showFragment(getInitialFragment()) if (result.initialSetup) showFragment(getInitialFragment())
else supportFragmentManager.popBackStack() else supportFragmentManager.popBackStack()
@ -44,21 +41,6 @@ abstract class BackupActivity : AppCompatActivity() {
}) })
} }
@CallSuper
override fun onStart() {
super.onStart()
if (isFinishing) return
// check that backup is provisioned
if (!getViewModel().recoveryCodeIsSet()) {
showRecoveryCodeActivity()
} else if (!getViewModel().validLocationIsSet()) {
showFragment(BackupLocationFragment())
// remove potential error notifications
(application as Backup).notificationManager.onBackupErrorSeen()
}
}
@CallSuper @CallSuper
override fun onActivityResult(requestCode: Int, resultCode: Int, result: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, result: Intent?) {
if (resultCode == RESULT_OK && requestCode == REQUEST_CODE_OPEN_DOCUMENT_TREE) { if (resultCode == RESULT_OK && requestCode == REQUEST_CODE_OPEN_DOCUMENT_TREE) {
@ -80,9 +62,9 @@ abstract class BackupActivity : AppCompatActivity() {
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
private fun showRecoveryCodeActivity() { protected fun showRecoveryCodeActivity() {
val intent = Intent(this, RecoveryCodeActivity::class.java) val intent = Intent(this, RecoveryCodeActivity::class.java)
intent.putExtra(INTENT_EXTRA_IS_RESTORE, isRestoreOperation()) intent.putExtra(INTENT_EXTRA_IS_RESTORE, getViewModel().isRestoreOperation)
startActivityForResult(intent, REQUEST_CODE_RECOVERY_CODE) startActivityForResult(intent, REQUEST_CODE_RECOVERY_CODE)
} }

View file

@ -1,32 +1,26 @@
package com.stevesoltys.backup.ui package com.stevesoltys.backup.ui
import android.app.Application import android.app.Application
import android.app.backup.BackupProgress
import android.app.backup.IBackupObserver
import android.content.Intent import android.content.Intent
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
import android.net.Uri import android.net.Uri
import android.util.Log
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import com.stevesoltys.backup.Backup import com.stevesoltys.backup.Backup
import com.stevesoltys.backup.isOnExternalStorage import com.stevesoltys.backup.isOnExternalStorage
import com.stevesoltys.backup.settings.getBackupFolderUri import com.stevesoltys.backup.settings.getBackupFolderUri
import com.stevesoltys.backup.settings.setBackupFolderUri
import com.stevesoltys.backup.transport.ConfigurableBackupTransportService
import com.stevesoltys.backup.transport.TRANSPORT_ID
private val TAG = BackupViewModel::class.java.simpleName private val TAG = BackupViewModel::class.java.simpleName
abstract class BackupViewModel(protected val app: Application) : AndroidViewModel(app) { abstract class BackupViewModel(protected val app: Application) : AndroidViewModel(app) {
private val locationWasSet = MutableLiveEvent<LocationResult>() protected val mLocationSet = MutableLiveEvent<LocationResult>()
/** /**
* Will be set to true if this is the initial location. * Will be set to true if this is the initial location.
* It will be false if an existing location was changed. * It will be false if an existing location was changed.
*/ */
internal val onLocationSet: LiveEvent<LocationResult> get() = locationWasSet internal val locationSet: LiveEvent<LocationResult> get() = mLocationSet
private val mChooseBackupLocation = MutableLiveEvent<Boolean>() private val mChooseBackupLocation = MutableLiveEvent<Boolean>()
internal val chooseBackupLocation: LiveEvent<Boolean> get() = mChooseBackupLocation internal val chooseBackupLocation: LiveEvent<Boolean> get() = mChooseBackupLocation
@ -41,6 +35,8 @@ abstract class BackupViewModel(protected val app: Application) : AndroidViewMode
return file.isDirectory return file.isDirectory
} }
abstract val isRestoreOperation: Boolean
internal fun handleChooseFolderResult(result: Intent?) { internal fun handleChooseFolderResult(result: Intent?) {
val folderUri = result?.data ?: return val folderUri = result?.data ?: return
@ -48,53 +44,10 @@ abstract class BackupViewModel(protected val app: Application) : AndroidViewMode
val takeFlags = result.flags and (FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION) val takeFlags = result.flags and (FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION)
app.contentResolver.takePersistableUriPermission(folderUri, takeFlags) app.contentResolver.takePersistableUriPermission(folderUri, takeFlags)
// check if this is initial set-up or a later change onLocationSet(folderUri, !validLocationIsSet())
val initialSetUp = !validLocationIsSet()
if (acceptBackupLocation(folderUri)) {
// store backup folder location in settings
setBackupFolderUri(app, folderUri)
// stop backup service to be sure the old location will get updated
app.stopService(Intent(app, ConfigurableBackupTransportService::class.java))
Log.d(TAG, "New storage location chosen: $folderUri")
// initialize the new location
// TODO don't do this when restoring
Backup.backupManager.initializeTransports(arrayOf(TRANSPORT_ID), InitializationObserver(initialSetUp))
} else {
Log.w(TAG, "Location was rejected: $folderUri")
// notify the UI that the location was invalid
locationWasSet.setEvent(LocationResult(false, initialSetUp))
}
} }
protected open fun acceptBackupLocation(folderUri: Uri): Boolean { abstract fun onLocationSet(folderUri: Uri, isInitialSetup: Boolean)
return true
}
private inner class InitializationObserver(private val initialSetUp: Boolean) : IBackupObserver.Stub() {
override fun onUpdate(currentBackupPackage: String, backupProgress: BackupProgress) {
// noop
}
override fun onResult(target: String, status: Int) {
// noop
}
override fun backupFinished(status: Int) {
if (Log.isLoggable(TAG, Log.INFO)) {
Log.i(TAG, "Initialization finished. Status: $status")
}
if (status == 0) {
// notify the UI that the location has been set
locationWasSet.postEvent(LocationResult(true, initialSetUp))
} else {
// notify the UI that the location was invalid
locationWasSet.postEvent(LocationResult(false, initialSetUp))
}
}
}
} }

View file

@ -75,6 +75,8 @@
<string name="restore_title">Restore from Backup</string> <string name="restore_title">Restore from Backup</string>
<string name="restore_choose_restore_set">Choose a backup to restore</string> <string name="restore_choose_restore_set">Choose a backup to restore</string>
<string name="restore_back">Don\'t restore</string> <string name="restore_back">Don\'t restore</string>
<string name="restore_invalid_location_title">No backups found</string>
<string name="restore_invalid_location_message">We could not find any backups at this location.\n\nPlease choose another location that contains a %s folder.</string>
<string name="restore_set_error">An error occurred loading the backups.</string> <string name="restore_set_error">An error occurred loading the backups.</string>
<string name="restore_set_empty_result">No backups found at given location.</string> <string name="restore_set_empty_result">No backups found at given location.</string>
<string name="restore_restoring">Restoring Backup</string> <string name="restore_restoring">Restoring Backup</string>