diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b8ecebc9..ce44359f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -14,6 +14,11 @@
android:name="android.permission.BACKUP"
tools:ignore="ProtectedPermissions" />
+
+
+
+ android:name=".ui.storage.StorageActivity"
+ android:theme="@style/AppTheme.NoActionBar" />
+
+
+
+
VERSION) throw UnsupportedVersionException(version)
- val metadataBytes = crypto.decryptSegment(inputStream)
+ val metadataBytes = try {
+ crypto.decryptSegment(inputStream)
+ } catch (e: AEADBadTagException) {
+ // TODO use yet another exception?
+ throw SecurityException(e)
+ }
return decode(metadataBytes, version, expectedToken)
}
diff --git a/app/src/main/java/com/stevesoltys/backup/restore/RestoreActivity.kt b/app/src/main/java/com/stevesoltys/backup/restore/RestoreActivity.kt
index 1c0689d2..b09f7ecb 100644
--- a/app/src/main/java/com/stevesoltys/backup/restore/RestoreActivity.kt
+++ b/app/src/main/java/com/stevesoltys/backup/restore/RestoreActivity.kt
@@ -2,22 +2,17 @@ package com.stevesoltys.backup.restore
import android.os.Bundle
import androidx.annotation.CallSuper
-import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
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.BackupLocationFragment
-import com.stevesoltys.backup.ui.BackupViewModel
+import com.stevesoltys.backup.ui.RequireProvisioningActivity
+import com.stevesoltys.backup.ui.RequireProvisioningViewModel
-class RestoreActivity : BackupActivity() {
+class RestoreActivity : RequireProvisioningActivity() {
private lateinit var viewModel: RestoreViewModel
- override fun getViewModel(): BackupViewModel = viewModel
-
- override fun getInitialFragment() = RestoreSetFragment()
+ override fun getViewModel(): RequireProvisioningViewModel = viewModel
override fun onCreate(savedInstanceState: Bundle?) {
viewModel = ViewModelProviders.of(this).get(RestoreViewModel::class.java)
@@ -29,8 +24,8 @@ class RestoreActivity : BackupActivity() {
if (set != null) showFragment(RestoreProgressFragment())
})
- if (savedInstanceState == null && viewModel.validLocationIsSet()) {
- showFragment(getInitialFragment())
+ if (savedInstanceState == null) {
+ showFragment(RestoreSetFragment())
}
}
@@ -41,18 +36,10 @@ class RestoreActivity : BackupActivity() {
// check that backup is provisioned
if (!viewModel.validLocationIsSet()) {
- showFragment(BackupLocationFragment())
+ showStorageActivity()
} else if (!viewModel.recoveryCodeIsSet()) {
showRecoveryCodeActivity()
}
}
- override fun onInvalidLocation() {
- 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()
- }
-
}
diff --git a/app/src/main/java/com/stevesoltys/backup/restore/RestoreViewModel.kt b/app/src/main/java/com/stevesoltys/backup/restore/RestoreViewModel.kt
index 24a1a2c3..20db414c 100644
--- a/app/src/main/java/com/stevesoltys/backup/restore/RestoreViewModel.kt
+++ b/app/src/main/java/com/stevesoltys/backup/restore/RestoreViewModel.kt
@@ -4,32 +4,24 @@ import android.app.Application
import android.app.backup.IRestoreObserver
import android.app.backup.IRestoreSession
import android.app.backup.RestoreSet
-import android.content.Intent
-import android.net.Uri
import android.util.Log
import androidx.annotation.WorkerThread
-import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.stevesoltys.backup.Backup
import com.stevesoltys.backup.R
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.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.LocationResult
+import com.stevesoltys.backup.ui.RequireProvisioningViewModel
private val TAG = RestoreViewModel::class.java.simpleName
-class RestoreViewModel(app: Application) : BackupViewModel(app), RestoreSetClickListener {
-
- private val backupManager = Backup.backupManager
+class RestoreViewModel(app: Application) : RequireProvisioningViewModel(app), RestoreSetClickListener {
override val isRestoreOperation = true
+ private val backupManager = Backup.backupManager
+
private var session: IRestoreSession? = null
private var observer: RestoreObserver? = null
private val monitor = BackupMonitor()
@@ -50,41 +42,6 @@ class RestoreViewModel(app: Application) : BackupViewModel(app), RestoreSetClick
// Zero on success; a nonzero error code if the restore operation as a whole failed.
internal val restoreFinished: LiveData get() = mRestoreFinished
- override fun onLocationSet(folderUri: Uri, isInitialSetup: Boolean) {
- if (hasBackup(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")
-
- 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() {
val session = this.session ?: backupManager.beginRestoreSession(null, TRANSPORT_ID)
this.session = session
diff --git a/app/src/main/java/com/stevesoltys/backup/settings/SettingsActivity.kt b/app/src/main/java/com/stevesoltys/backup/settings/SettingsActivity.kt
index bb86976b..b9b0756e 100644
--- a/app/src/main/java/com/stevesoltys/backup/settings/SettingsActivity.kt
+++ b/app/src/main/java/com/stevesoltys/backup/settings/SettingsActivity.kt
@@ -5,17 +5,14 @@ import androidx.annotation.CallSuper
import androidx.lifecycle.ViewModelProviders
import com.stevesoltys.backup.Backup
import com.stevesoltys.backup.R
-import com.stevesoltys.backup.ui.BackupActivity
-import com.stevesoltys.backup.ui.BackupLocationFragment
-import com.stevesoltys.backup.ui.BackupViewModel
+import com.stevesoltys.backup.ui.RequireProvisioningActivity
+import com.stevesoltys.backup.ui.RequireProvisioningViewModel
-class SettingsActivity : BackupActivity() {
+class SettingsActivity : RequireProvisioningActivity() {
private lateinit var viewModel: SettingsViewModel
- override fun getViewModel(): BackupViewModel = viewModel
-
- override fun getInitialFragment() = SettingsFragment()
+ override fun getViewModel(): RequireProvisioningViewModel = viewModel
override fun onCreate(savedInstanceState: Bundle?) {
viewModel = ViewModelProviders.of(this).get(SettingsViewModel::class.java)
@@ -25,7 +22,7 @@ class SettingsActivity : BackupActivity() {
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
- if (savedInstanceState == null) showFragment(getInitialFragment())
+ if (savedInstanceState == null) showFragment(SettingsFragment())
}
@CallSuper
@@ -37,7 +34,7 @@ class SettingsActivity : BackupActivity() {
if (!viewModel.recoveryCodeIsSet()) {
showRecoveryCodeActivity()
} else if (!viewModel.validLocationIsSet()) {
- showFragment(BackupLocationFragment())
+ showStorageActivity()
// remove potential error notifications
(application as Backup).notificationManager.onBackupErrorSeen()
}
diff --git a/app/src/main/java/com/stevesoltys/backup/settings/SettingsFragment.kt b/app/src/main/java/com/stevesoltys/backup/settings/SettingsFragment.kt
index 529acd9f..111ae599 100644
--- a/app/src/main/java/com/stevesoltys/backup/settings/SettingsFragment.kt
+++ b/app/src/main/java/com/stevesoltys/backup/settings/SettingsFragment.kt
@@ -36,7 +36,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
viewModel = ViewModelProviders.of(requireActivity()).get(SettingsViewModel::class.java)
- backup = findPreference("backup")!!
+ backup = findPreference("backup")!!
backup.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
val enabled = newValue as Boolean
try {
@@ -55,7 +55,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
true
}
- autoRestore = findPreference("auto_restore")!!
+ autoRestore = findPreference("auto_restore")!!
autoRestore.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
val enabled = newValue as Boolean
try {
diff --git a/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt b/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt
index dcd10a37..033a8dd3 100644
--- a/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt
+++ b/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt
@@ -1,61 +1,15 @@
package com.stevesoltys.backup.settings
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.ui.BackupViewModel
-import com.stevesoltys.backup.ui.LocationResult
+import com.stevesoltys.backup.ui.RequireProvisioningViewModel
private val TAG = SettingsViewModel::class.java.simpleName
-class SettingsViewModel(app: Application) : BackupViewModel(app) {
+class SettingsViewModel(app: Application) : RequireProvisioningViewModel(app) {
override val isRestoreOperation = false
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))
- }
- }
- }
-
}
diff --git a/app/src/main/java/com/stevesoltys/backup/ui/BackupActivity.kt b/app/src/main/java/com/stevesoltys/backup/ui/BackupActivity.kt
index 5880013a..0ff072fe 100644
--- a/app/src/main/java/com/stevesoltys/backup/ui/BackupActivity.kt
+++ b/app/src/main/java/com/stevesoltys/backup/ui/BackupActivity.kt
@@ -1,58 +1,13 @@
package com.stevesoltys.backup.ui
-import android.content.Intent
-import android.os.Bundle
-import android.util.Log
import android.view.MenuItem
-import android.widget.Toast
-import android.widget.Toast.LENGTH_LONG
import androidx.annotation.CallSuper
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import com.stevesoltys.backup.R
-const val REQUEST_CODE_OPEN_DOCUMENT_TREE = 1
-const val REQUEST_CODE_RECOVERY_CODE = 2
-
-private val TAG = BackupActivity::class.java.name
-
-/**
- * An Activity that requires the recovery code and the backup location to be set up
- * before starting.
- */
abstract class BackupActivity : AppCompatActivity() {
- protected abstract fun getViewModel(): BackupViewModel
-
- protected abstract fun getInitialFragment(): Fragment
-
- @CallSuper
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- getViewModel().locationSet.observeEvent(this, LiveEventHandler { result ->
- if (result.validLocation) {
- if (result.initialSetup) showFragment(getInitialFragment())
- else supportFragmentManager.popBackStack()
- } else onInvalidLocation()
- })
- getViewModel().chooseBackupLocation.observeEvent(this, LiveEventHandler { show ->
- if (show) showFragment(BackupLocationFragment(), true)
- })
- }
-
- @CallSuper
- override fun onActivityResult(requestCode: Int, resultCode: Int, result: Intent?) {
- if (resultCode == RESULT_OK && requestCode == REQUEST_CODE_OPEN_DOCUMENT_TREE) {
- getViewModel().handleChooseFolderResult(result)
- } else if (resultCode != RESULT_OK) {
- Log.w(TAG, "Error in activity result: $requestCode")
- finishAfterTransition()
- } else {
- super.onActivityResult(requestCode, resultCode, result)
- }
- }
-
@CallSuper
override fun onOptionsItemSelected(item: MenuItem): Boolean = when {
item.itemId == android.R.id.home -> {
@@ -62,16 +17,6 @@ abstract class BackupActivity : AppCompatActivity() {
else -> super.onOptionsItemSelected(item)
}
- protected fun showRecoveryCodeActivity() {
- val intent = Intent(this, RecoveryCodeActivity::class.java)
- intent.putExtra(INTENT_EXTRA_IS_RESTORE, getViewModel().isRestoreOperation)
- startActivityForResult(intent, REQUEST_CODE_RECOVERY_CODE)
- }
-
- protected open fun onInvalidLocation() {
- Toast.makeText(this, getString(R.string.settings_backup_location_invalid), LENGTH_LONG).show()
- }
-
protected fun showFragment(f: Fragment, addToBackStack: Boolean = false) {
val fragmentTransaction = supportFragmentManager.beginTransaction()
.replace(R.id.fragment, f)
diff --git a/app/src/main/java/com/stevesoltys/backup/ui/BackupLocationFragment.kt b/app/src/main/java/com/stevesoltys/backup/ui/BackupLocationFragment.kt
deleted file mode 100644
index 7007ede0..00000000
--- a/app/src/main/java/com/stevesoltys/backup/ui/BackupLocationFragment.kt
+++ /dev/null
@@ -1,46 +0,0 @@
-package com.stevesoltys.backup.ui
-
-import android.content.ActivityNotFoundException
-import android.content.Intent
-import android.content.Intent.*
-import android.os.Bundle
-import android.provider.DocumentsContract.EXTRA_PROMPT
-import android.widget.Toast
-import android.widget.Toast.LENGTH_LONG
-import androidx.preference.Preference
-import androidx.preference.PreferenceFragmentCompat
-import com.stevesoltys.backup.R
-
-class BackupLocationFragment : PreferenceFragmentCompat() {
-
- override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
- setPreferencesFromResource(R.xml.backup_location, rootKey)
-
- requireActivity().setTitle(R.string.settings_backup_location_title)
-
- val externalStorage = Preference(requireContext()).apply {
- setIcon(R.drawable.ic_storage)
- setTitle(R.string.settings_backup_external_storage)
- setOnPreferenceClickListener {
- showChooseFolderActivity()
- true
- }
- }
- preferenceScreen.addPreference(externalStorage)
- }
-
- private fun showChooseFolderActivity() {
- val openTreeIntent = Intent(ACTION_OPEN_DOCUMENT_TREE)
- openTreeIntent.putExtra(EXTRA_PROMPT, getString(R.string.settings_backup_location_picker))
- openTreeIntent.addFlags(FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
- FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION)
- try {
- val documentChooser = createChooser(openTreeIntent, null)
- // start from the activity context, so we can receive and handle the result there
- requireActivity().startActivityForResult(documentChooser, REQUEST_CODE_OPEN_DOCUMENT_TREE)
- } catch (ex: ActivityNotFoundException) {
- Toast.makeText(requireContext(), "Please install a file manager.", LENGTH_LONG).show()
- }
- }
-
-}
diff --git a/app/src/main/java/com/stevesoltys/backup/ui/BackupViewModel.kt b/app/src/main/java/com/stevesoltys/backup/ui/BackupViewModel.kt
deleted file mode 100644
index 25cb74ed..00000000
--- a/app/src/main/java/com/stevesoltys/backup/ui/BackupViewModel.kt
+++ /dev/null
@@ -1,54 +0,0 @@
-package com.stevesoltys.backup.ui
-
-import android.app.Application
-import android.content.Intent
-import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
-import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
-import android.net.Uri
-import androidx.documentfile.provider.DocumentFile
-import androidx.lifecycle.AndroidViewModel
-import com.stevesoltys.backup.Backup
-import com.stevesoltys.backup.isOnExternalStorage
-import com.stevesoltys.backup.settings.getBackupFolderUri
-
-private val TAG = BackupViewModel::class.java.simpleName
-
-abstract class BackupViewModel(protected val app: Application) : AndroidViewModel(app) {
-
- protected val mLocationSet = MutableLiveEvent()
- /**
- * Will be set to true if this is the initial location.
- * It will be false if an existing location was changed.
- */
- internal val locationSet: LiveEvent get() = mLocationSet
-
- private val mChooseBackupLocation = MutableLiveEvent()
- internal val chooseBackupLocation: LiveEvent get() = mChooseBackupLocation
- internal fun chooseBackupLocation() = mChooseBackupLocation.setEvent(true)
-
- internal fun recoveryCodeIsSet() = Backup.keyManager.hasBackupKey()
-
- internal fun validLocationIsSet(): Boolean {
- val uri = getBackupFolderUri(app) ?: return false
- if (uri.isOnExternalStorage()) return true // might be a temporary failure
- val file = DocumentFile.fromTreeUri(app, uri) ?: return false
- return file.isDirectory
- }
-
- abstract val isRestoreOperation: Boolean
-
- internal fun handleChooseFolderResult(result: Intent?) {
- val folderUri = result?.data ?: return
-
- // persist permission to access backup folder across reboots
- val takeFlags = result.flags and (FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION)
- app.contentResolver.takePersistableUriPermission(folderUri, takeFlags)
-
- onLocationSet(folderUri, !validLocationIsSet())
- }
-
- abstract fun onLocationSet(folderUri: Uri, isInitialSetup: Boolean)
-
-}
-
-class LocationResult(val validLocation: Boolean, val initialSetup: Boolean)
diff --git a/app/src/main/java/com/stevesoltys/backup/ui/RequireProvisioningActivity.kt b/app/src/main/java/com/stevesoltys/backup/ui/RequireProvisioningActivity.kt
new file mode 100644
index 00000000..7c2172dc
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/ui/RequireProvisioningActivity.kt
@@ -0,0 +1,68 @@
+package com.stevesoltys.backup.ui
+
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import androidx.annotation.CallSuper
+import com.stevesoltys.backup.ui.recoverycode.RecoveryCodeActivity
+import com.stevesoltys.backup.ui.storage.StorageActivity
+
+const val REQUEST_CODE_OPEN_DOCUMENT_TREE = 1
+const val REQUEST_CODE_BACKUP_LOCATION = 2
+const val REQUEST_CODE_RECOVERY_CODE = 3
+
+const val INTENT_EXTRA_IS_RESTORE = "isRestore"
+
+private val TAG = RequireProvisioningActivity::class.java.name
+
+/**
+ * An Activity that requires the recovery code and the backup location to be set up
+ * before starting.
+ */
+abstract class RequireProvisioningActivity : BackupActivity() {
+
+ protected abstract fun getViewModel(): RequireProvisioningViewModel
+
+ @CallSuper
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ getViewModel().chooseBackupLocation.observeEvent(this, LiveEventHandler { show ->
+ if (show) showStorageActivity()
+ })
+ }
+
+ @CallSuper
+ override fun onActivityResult(requestCode: Int, resultCode: Int, result: Intent?) {
+ if (requestCode == REQUEST_CODE_BACKUP_LOCATION && resultCode != RESULT_OK) {
+ Log.w(TAG, "Error in activity result: $requestCode")
+ if (!getViewModel().validLocationIsSet()) {
+ finishAfterTransition()
+ }
+ } else if (requestCode == REQUEST_CODE_RECOVERY_CODE && resultCode != RESULT_OK) {
+ Log.w(TAG, "Error in activity result: $requestCode")
+ if (!getViewModel().recoveryCodeIsSet()) {
+ finishAfterTransition()
+ }
+ } else {
+ super.onActivityResult(requestCode, resultCode, result)
+ }
+ }
+
+ protected fun showStorageActivity() {
+ val intent = Intent(this, StorageActivity::class.java)
+ intent.putExtra(INTENT_EXTRA_IS_RESTORE, getViewModel().isRestoreOperation)
+ startActivityForResult(intent, REQUEST_CODE_BACKUP_LOCATION)
+ }
+
+ protected fun showRecoveryCodeActivity() {
+ val intent = Intent(this, RecoveryCodeActivity::class.java)
+ intent.putExtra(INTENT_EXTRA_IS_RESTORE, getViewModel().isRestoreOperation)
+ startActivityForResult(intent, REQUEST_CODE_RECOVERY_CODE)
+ }
+
+ protected fun isProvisioned(): Boolean {
+ return getViewModel().recoveryCodeIsSet() && getViewModel().validLocationIsSet()
+ }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/ui/RequireProvisioningViewModel.kt b/app/src/main/java/com/stevesoltys/backup/ui/RequireProvisioningViewModel.kt
new file mode 100644
index 00000000..59dce1fe
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/ui/RequireProvisioningViewModel.kt
@@ -0,0 +1,20 @@
+package com.stevesoltys.backup.ui
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import com.stevesoltys.backup.Backup
+import com.stevesoltys.backup.ui.storage.StorageViewModel
+
+abstract class RequireProvisioningViewModel(protected val app: Application) : AndroidViewModel(app) {
+
+ abstract val isRestoreOperation: Boolean
+
+ private val mChooseBackupLocation = MutableLiveEvent()
+ internal val chooseBackupLocation: LiveEvent get() = mChooseBackupLocation
+ internal fun chooseBackupLocation() = mChooseBackupLocation.setEvent(true)
+
+ internal fun validLocationIsSet() = StorageViewModel.validLocationIsSet(app)
+
+ internal fun recoveryCodeIsSet() = Backup.keyManager.hasBackupKey()
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/ui/RecoveryCodeActivity.kt b/app/src/main/java/com/stevesoltys/backup/ui/recoverycode/RecoveryCodeActivity.kt
similarity index 92%
rename from app/src/main/java/com/stevesoltys/backup/ui/RecoveryCodeActivity.kt
rename to app/src/main/java/com/stevesoltys/backup/ui/recoverycode/RecoveryCodeActivity.kt
index e34650f4..676b6b3e 100644
--- a/app/src/main/java/com/stevesoltys/backup/ui/RecoveryCodeActivity.kt
+++ b/app/src/main/java/com/stevesoltys/backup/ui/recoverycode/RecoveryCodeActivity.kt
@@ -1,12 +1,12 @@
-package com.stevesoltys.backup.ui
+package com.stevesoltys.backup.ui.recoverycode
import android.os.Bundle
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModelProviders
import com.stevesoltys.backup.R
-
-internal const val INTENT_EXTRA_IS_RESTORE = "isRestore"
+import com.stevesoltys.backup.ui.INTENT_EXTRA_IS_RESTORE
+import com.stevesoltys.backup.ui.LiveEventHandler
class RecoveryCodeActivity : AppCompatActivity() {
@@ -29,8 +29,6 @@ class RecoveryCodeActivity : AppCompatActivity() {
}
})
- supportActionBar!!.setDisplayHomeAsUpEnabled(true)
-
if (savedInstanceState == null) {
if (viewModel.isRestore) showInput(false)
else showOutput()
diff --git a/app/src/main/java/com/stevesoltys/backup/ui/RecoveryCodeAdapter.kt b/app/src/main/java/com/stevesoltys/backup/ui/recoverycode/RecoveryCodeAdapter.kt
similarity index 96%
rename from app/src/main/java/com/stevesoltys/backup/ui/RecoveryCodeAdapter.kt
rename to app/src/main/java/com/stevesoltys/backup/ui/recoverycode/RecoveryCodeAdapter.kt
index 28f7ecdd..9db18b10 100644
--- a/app/src/main/java/com/stevesoltys/backup/ui/RecoveryCodeAdapter.kt
+++ b/app/src/main/java/com/stevesoltys/backup/ui/recoverycode/RecoveryCodeAdapter.kt
@@ -1,4 +1,4 @@
-package com.stevesoltys.backup.ui
+package com.stevesoltys.backup.ui.recoverycode
import android.view.LayoutInflater
import android.view.View
diff --git a/app/src/main/java/com/stevesoltys/backup/ui/RecoveryCodeInputFragment.kt b/app/src/main/java/com/stevesoltys/backup/ui/recoverycode/RecoveryCodeInputFragment.kt
similarity index 98%
rename from app/src/main/java/com/stevesoltys/backup/ui/RecoveryCodeInputFragment.kt
rename to app/src/main/java/com/stevesoltys/backup/ui/recoverycode/RecoveryCodeInputFragment.kt
index a8bb5115..a6760b09 100644
--- a/app/src/main/java/com/stevesoltys/backup/ui/RecoveryCodeInputFragment.kt
+++ b/app/src/main/java/com/stevesoltys/backup/ui/recoverycode/RecoveryCodeInputFragment.kt
@@ -1,4 +1,4 @@
-package com.stevesoltys.backup.ui
+package com.stevesoltys.backup.ui.recoverycode
import android.os.Bundle
import android.view.LayoutInflater
diff --git a/app/src/main/java/com/stevesoltys/backup/ui/RecoveryCodeOutputFragment.kt b/app/src/main/java/com/stevesoltys/backup/ui/recoverycode/RecoveryCodeOutputFragment.kt
similarity index 97%
rename from app/src/main/java/com/stevesoltys/backup/ui/RecoveryCodeOutputFragment.kt
rename to app/src/main/java/com/stevesoltys/backup/ui/recoverycode/RecoveryCodeOutputFragment.kt
index 03297b53..afdb54b6 100644
--- a/app/src/main/java/com/stevesoltys/backup/ui/RecoveryCodeOutputFragment.kt
+++ b/app/src/main/java/com/stevesoltys/backup/ui/recoverycode/RecoveryCodeOutputFragment.kt
@@ -1,4 +1,4 @@
-package com.stevesoltys.backup.ui
+package com.stevesoltys.backup.ui.recoverycode
import android.content.res.Configuration
import android.os.Bundle
diff --git a/app/src/main/java/com/stevesoltys/backup/ui/RecoveryCodeViewModel.kt b/app/src/main/java/com/stevesoltys/backup/ui/recoverycode/RecoveryCodeViewModel.kt
similarity index 91%
rename from app/src/main/java/com/stevesoltys/backup/ui/RecoveryCodeViewModel.kt
rename to app/src/main/java/com/stevesoltys/backup/ui/recoverycode/RecoveryCodeViewModel.kt
index 8cbc97e2..8fdd8736 100644
--- a/app/src/main/java/com/stevesoltys/backup/ui/RecoveryCodeViewModel.kt
+++ b/app/src/main/java/com/stevesoltys/backup/ui/recoverycode/RecoveryCodeViewModel.kt
@@ -1,8 +1,10 @@
-package com.stevesoltys.backup.ui
+package com.stevesoltys.backup.ui.recoverycode
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import com.stevesoltys.backup.Backup
+import com.stevesoltys.backup.ui.LiveEvent
+import com.stevesoltys.backup.ui.MutableLiveEvent
import io.github.novacrypto.bip39.*
import io.github.novacrypto.bip39.Validation.InvalidChecksumException
import io.github.novacrypto.bip39.Validation.InvalidWordCountException
@@ -45,6 +47,9 @@ class RecoveryCodeViewModel(application: Application) : AndroidViewModel(applica
} catch (e: InvalidWordCountException) {
throw AssertionError(e)
}
+
+ // TODO if (isRestore) check if we can decrypt a backup
+
val mnemonic = input.joinToString(" ")
val seed = SeedCalculator(JavaxPBKDF2WithHmacSHA512.INSTANCE).calculateSeed(mnemonic, "")
Backup.keyManager.storeBackupKey(seed)
diff --git a/app/src/main/java/com/stevesoltys/backup/ui/storage/BackupStorageViewModel.kt b/app/src/main/java/com/stevesoltys/backup/ui/storage/BackupStorageViewModel.kt
new file mode 100644
index 00000000..349ac0b2
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/ui/storage/BackupStorageViewModel.kt
@@ -0,0 +1,67 @@
+package com.stevesoltys.backup.ui.storage
+
+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.R
+import com.stevesoltys.backup.settings.getAndSaveNewBackupToken
+import com.stevesoltys.backup.settings.setBackupFolderUri
+import com.stevesoltys.backup.transport.ConfigurableBackupTransportService
+import com.stevesoltys.backup.transport.TRANSPORT_ID
+
+private val TAG = BackupStorageViewModel::class.java.simpleName
+
+internal class BackupStorageViewModel(private val app: Application) : StorageViewModel(app) {
+
+ override val isRestoreOperation = false
+
+ override fun onLocationSet(uri: Uri) {
+ // store backup folder location in settings
+ setBackupFolderUri(app, uri)
+
+ // TODO also set the storage name
+
+ // stop backup service to be sure the old location will get updated
+ app.stopService(Intent(app, ConfigurableBackupTransportService::class.java))
+
+ // use a new backup token
+ getAndSaveNewBackupToken(app)
+
+ Log.d(TAG, "New storage location chosen: $uri")
+
+ // initialize the new location
+ val observer = InitializationObserver()
+ Backup.backupManager.initializeTransports(arrayOf(TRANSPORT_ID), observer)
+ }
+
+ @WorkerThread
+ private inner class InitializationObserver : 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
+ mLocationChecked.postEvent(LocationResult())
+ } else {
+ // notify the UI that the location was invalid
+ val errorMsg = app.getString(R.string.storage_check_fragment_backup_error)
+ mLocationChecked.postEvent(LocationResult(errorMsg))
+ }
+ }
+ }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/ui/storage/PermissionGrantActivity.kt b/app/src/main/java/com/stevesoltys/backup/ui/storage/PermissionGrantActivity.kt
new file mode 100644
index 00000000..ee16c9da
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/ui/storage/PermissionGrantActivity.kt
@@ -0,0 +1,19 @@
+package com.stevesoltys.backup.ui.storage
+
+import android.content.Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+
+class PermissionGrantActivity : AppCompatActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ if (intent?.data != null) {
+ intent.addFlags(FLAG_GRANT_PREFIX_URI_PERMISSION)
+ setResult(RESULT_OK, intent)
+ }
+ finish()
+ }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/ui/storage/RestoreStorageViewModel.kt b/app/src/main/java/com/stevesoltys/backup/ui/storage/RestoreStorageViewModel.kt
new file mode 100644
index 00000000..f66995c6
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/ui/storage/RestoreStorageViewModel.kt
@@ -0,0 +1,56 @@
+package com.stevesoltys.backup.ui.storage
+
+import android.app.Application
+import android.content.Intent
+import android.net.Uri
+import android.util.Log
+import androidx.documentfile.provider.DocumentFile
+import com.stevesoltys.backup.R
+import com.stevesoltys.backup.settings.setBackupFolderUri
+import com.stevesoltys.backup.transport.ConfigurableBackupTransportService
+import com.stevesoltys.backup.transport.backup.plugins.DIRECTORY_ROOT
+import com.stevesoltys.backup.transport.restore.plugins.DocumentsProviderRestorePlugin
+
+private val TAG = RestoreStorageViewModel::class.java.simpleName
+
+internal class RestoreStorageViewModel(private val app: Application) : StorageViewModel(app) {
+
+ override val isRestoreOperation = true
+
+ override fun onLocationSet(uri: Uri) {
+ if (hasBackup(uri)) {
+ // store backup folder location in settings
+ setBackupFolderUri(app, uri)
+
+ // 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: $uri")
+
+ mLocationChecked.setEvent(LocationResult())
+ } else {
+ Log.w(TAG, "Location was rejected: $uri")
+
+ // notify the UI that the location was invalid
+ val errorMsg = app.getString(R.string.restore_invalid_location_message, DIRECTORY_ROOT)
+ mLocationChecked.setEvent(LocationResult(errorMsg))
+ }
+ }
+
+ /**
+ * 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 = DocumentsProviderRestorePlugin.getBackups(rootDir)
+ return backupSets.isNotEmpty()
+ }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageActivity.kt b/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageActivity.kt
new file mode 100644
index 00000000..434f1e06
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageActivity.kt
@@ -0,0 +1,92 @@
+package com.stevesoltys.backup.ui.storage
+
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import androidx.annotation.CallSuper
+import androidx.appcompat.app.AlertDialog
+import androidx.lifecycle.ViewModelProviders
+import com.stevesoltys.backup.R
+import com.stevesoltys.backup.ui.BackupActivity
+import com.stevesoltys.backup.ui.INTENT_EXTRA_IS_RESTORE
+import com.stevesoltys.backup.ui.LiveEventHandler
+
+private val TAG = StorageActivity::class.java.name
+
+class StorageActivity : BackupActivity() {
+
+ private lateinit var viewModel: StorageViewModel
+
+ @CallSuper
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContentView(R.layout.activity_fragment_container)
+
+ viewModel = if (isRestore()) {
+ ViewModelProviders.of(this).get(RestoreStorageViewModel::class.java)
+ } else {
+ ViewModelProviders.of(this).get(BackupStorageViewModel::class.java)
+ }
+
+ viewModel.locationSet.observeEvent(this, LiveEventHandler {
+ showFragment(StorageCheckFragment.newInstance(getCheckFragmentTitle()), true)
+ })
+
+ viewModel.locationChecked.observeEvent(this, LiveEventHandler { result ->
+ val errorMsg = result.errorMsg
+ if (errorMsg == null) {
+ setResult(RESULT_OK)
+ finishAfterTransition()
+ } else {
+ onInvalidLocation(errorMsg)
+ }
+ })
+
+ if (savedInstanceState == null) {
+ showFragment(StorageRootsFragment.newInstance(isRestore()))
+ }
+ }
+
+ @CallSuper
+ override fun onActivityResult(requestCode: Int, resultCode: Int, result: Intent?) {
+ if (resultCode != RESULT_OK) {
+ Log.w(TAG, "Error in activity result: $requestCode")
+ onInvalidLocation(getString(R.string.storage_check_fragment_permission_error))
+ } else {
+ super.onActivityResult(requestCode, resultCode, result)
+ }
+ }
+
+ override fun onBackPressed() {
+ if (supportFragmentManager.backStackEntryCount > 0) {
+ Log.d(TAG, "Blocking back button.")
+ } else {
+ super.onBackPressed()
+ }
+ }
+
+ private fun onInvalidLocation(errorMsg: String) {
+ if (viewModel.isRestoreOperation) {
+ supportFragmentManager.popBackStack()
+ AlertDialog.Builder(this)
+ .setTitle(getString(R.string.restore_invalid_location_title))
+ .setMessage(errorMsg)
+ .setPositiveButton(android.R.string.ok) { dialog, _ -> dialog.dismiss() }
+ .show()
+ } else {
+ showFragment(StorageCheckFragment.newInstance(getCheckFragmentTitle(), errorMsg))
+ }
+ }
+
+ private fun isRestore(): Boolean {
+ return intent?.getBooleanExtra(INTENT_EXTRA_IS_RESTORE, false) ?: false
+ }
+
+ private fun getCheckFragmentTitle() = if (viewModel.isRestoreOperation) {
+ getString(R.string.storage_check_fragment_restore_title)
+ } else {
+ getString(R.string.storage_check_fragment_backup_title)
+ }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageCheckFragment.kt b/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageCheckFragment.kt
new file mode 100644
index 00000000..5c488215
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageCheckFragment.kt
@@ -0,0 +1,49 @@
+package com.stevesoltys.backup.ui.storage
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.View.INVISIBLE
+import android.view.View.VISIBLE
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import com.stevesoltys.backup.R
+import kotlinx.android.synthetic.main.fragment_storage_check.*
+
+private const val TITLE = "title"
+private const val ERROR_MSG = "errorMsg"
+
+class StorageCheckFragment : Fragment() {
+
+ companion object {
+ fun newInstance(title: String, errorMsg: String? = null): StorageCheckFragment {
+ val f = StorageCheckFragment()
+ f.arguments = Bundle().apply {
+ putString(TITLE, title)
+ putString(ERROR_MSG, errorMsg)
+ }
+ return f
+ }
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?): View? {
+ return inflater.inflate(R.layout.fragment_storage_check, container, false)
+ }
+
+ override fun onActivityCreated(savedInstanceState: Bundle?) {
+ super.onActivityCreated(savedInstanceState)
+
+ titleView.text = arguments!!.getString(TITLE)
+
+ val errorMsg = arguments!!.getString(ERROR_MSG)
+ if (errorMsg != null) {
+ progressBar.visibility = INVISIBLE
+ errorView.text = errorMsg
+ errorView.visibility = VISIBLE
+ backButton.visibility = VISIBLE
+ backButton.setOnClickListener { requireActivity().supportFinishAfterTransition() }
+ }
+ }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageRootAdapter.kt b/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageRootAdapter.kt
new file mode 100644
index 00000000..6bbba65d
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageRootAdapter.kt
@@ -0,0 +1,97 @@
+package com.stevesoltys.backup.ui.storage
+
+
+import android.content.Context
+import android.text.format.Formatter
+import android.view.LayoutInflater
+import android.view.View
+import android.view.View.GONE
+import android.view.View.VISIBLE
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.appcompat.app.AlertDialog
+import androidx.recyclerview.widget.RecyclerView.Adapter
+import androidx.recyclerview.widget.RecyclerView.ViewHolder
+import com.stevesoltys.backup.R
+import com.stevesoltys.backup.ui.storage.StorageRootAdapter.StorageRootViewHolder
+
+internal class StorageRootAdapter(
+ private val isRestore: Boolean,
+ private val listener: StorageRootClickedListener) : Adapter() {
+
+ private val items = ArrayList()
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StorageRootViewHolder {
+ val v = LayoutInflater.from(parent.context)
+ .inflate(R.layout.list_item_storage_root, parent, false) as View
+ return StorageRootViewHolder(v)
+ }
+
+ override fun getItemCount() = items.size
+
+ override fun onBindViewHolder(holder: StorageRootViewHolder, position: Int) {
+ holder.bind(items[position])
+ }
+
+ internal fun setItems(items: List) {
+ this.items.clear()
+ this.items.addAll(items)
+ notifyDataSetChanged()
+ }
+
+ internal inner class StorageRootViewHolder(private val v: View) : ViewHolder(v) {
+
+ private val iconView = v.findViewById(R.id.iconView)
+ private val titleView = v.findViewById(R.id.titleView)
+ private val summaryView = v.findViewById(R.id.summaryView)
+
+ internal fun bind(item: StorageRoot) {
+ if (item.enabled) {
+ v.isEnabled = true
+ v.alpha = 1f
+ } else {
+ v.isEnabled = false
+ v.alpha = 0.3f
+ }
+
+ iconView.setImageDrawable(item.icon)
+ titleView.text = item.title
+ when {
+ item.summary != null -> {
+ summaryView.text = item.summary
+ summaryView.visibility = VISIBLE
+ }
+ item.availableBytes != null -> {
+ val str = Formatter.formatFileSize(v.context, item.availableBytes)
+ summaryView.text = v.context.getString(R.string.storage_available_bytes, str)
+ summaryView.visibility = VISIBLE
+ }
+ else -> summaryView.visibility = GONE
+ }
+ v.setOnClickListener {
+ if (!isRestore && item.isInternal()) {
+ showWarningDialog(v.context, item)
+ } else {
+ listener.onClick(item)
+ }
+ }
+ }
+
+ }
+
+ private fun showWarningDialog(context: Context, item: StorageRoot) {
+ AlertDialog.Builder(context)
+ .setTitle(R.string.storage_internal_warning_title)
+ .setMessage(R.string.storage_internal_warning_message)
+ .setPositiveButton(R.string.storage_internal_warning_choose_other) { dialog, _ ->
+ dialog.dismiss()
+ }
+ .setNegativeButton(R.string.storage_internal_warning_use_anyway) { dialog, _ ->
+ dialog.dismiss()
+ listener.onClick(item)
+ }
+ .show()
+ }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageRootFetcher.kt b/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageRootFetcher.kt
new file mode 100644
index 00000000..f0b74112
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageRootFetcher.kt
@@ -0,0 +1,208 @@
+package com.stevesoltys.backup.ui.storage
+
+import android.Manifest.permission.MANAGE_DOCUMENTS
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager.GET_META_DATA
+import android.content.pm.ProviderInfo
+import android.database.ContentObserver
+import android.database.Cursor
+import android.graphics.drawable.Drawable
+import android.net.Uri
+import android.os.Handler
+import android.provider.DocumentsContract
+import android.provider.DocumentsContract.PROVIDER_INTERFACE
+import android.provider.DocumentsContract.Root.*
+import android.util.Log
+import com.stevesoltys.backup.R
+import java.lang.Long.parseLong
+
+private val TAG = StorageRootFetcher::class.java.simpleName
+
+const val AUTHORITY_STORAGE = "com.android.externalstorage.documents"
+const val ROOT_ID_DEVICE = "primary"
+const val ROOT_ID_HOME = "home"
+
+const val AUTHORITY_DOWNLOADS = "com.android.providers.downloads.documents"
+
+data class StorageRoot(
+ internal val authority: String,
+ internal val rootId: String,
+ internal val documentId: String,
+ internal val icon: Drawable?,
+ internal val title: String,
+ internal val summary: String?,
+ internal val availableBytes: Long?,
+ internal val supportsEject: Boolean,
+ internal val enabled: Boolean = true) {
+
+ fun isInternal(): Boolean {
+ return authority == AUTHORITY_STORAGE && !supportsEject
+ }
+}
+
+internal interface RemovableStorageListener {
+ fun onStorageChanged()
+}
+
+internal class StorageRootFetcher(private val context: Context) {
+
+ private val packageManager = context.packageManager
+ private val contentResolver = context.contentResolver
+
+ private var listener: RemovableStorageListener? = null
+ private val observer = object : ContentObserver(Handler()) {
+ override fun onChange(selfChange: Boolean, uri: Uri?) {
+ super.onChange(selfChange, uri)
+ listener?.onStorageChanged()
+ }
+ }
+
+ internal fun setRemovableStorageListener(listener: RemovableStorageListener?) {
+ this.listener = listener
+ if (listener != null) {
+ val rootsUri = DocumentsContract.buildRootsUri(AUTHORITY_STORAGE)
+ contentResolver.registerContentObserver(rootsUri, true, observer)
+ } else {
+ contentResolver.unregisterContentObserver(observer)
+ }
+ }
+
+ internal fun getRemovableStorageListener() = listener
+
+ internal fun getStorageRoots(): List {
+ val roots = ArrayList()
+ val intent = Intent(PROVIDER_INTERFACE)
+ val providers = packageManager.queryIntentContentProviders(intent, 0)
+ for (info in providers) {
+ val providerInfo = info.providerInfo
+ val authority = providerInfo.authority
+ if (authority != null) {
+ roots.addAll(getRoots(providerInfo))
+ }
+ }
+ checkOrAddUsbRoot(roots)
+ return roots
+ }
+
+ private fun getRoots(providerInfo: ProviderInfo): List {
+ val authority = providerInfo.authority
+ val provider = packageManager.resolveContentProvider(authority, GET_META_DATA)
+ if (provider == null || !provider.isSupported()) {
+ Log.w(TAG, "Failed to get provider info for $authority")
+ return emptyList()
+ }
+
+ val roots = ArrayList()
+ val rootsUri = DocumentsContract.buildRootsUri(authority)
+
+ var cursor: Cursor? = null
+ try {
+ cursor = contentResolver.query(rootsUri, null, null, null, null)
+ while (cursor.moveToNext()) {
+ val root = getStorageRoot(authority, cursor)
+ if (root != null) roots.add(root)
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to load some roots from $authority", e)
+ } finally {
+ cursor?.close()
+ }
+ return roots
+ }
+
+ private fun getStorageRoot(authority: String, cursor: Cursor): StorageRoot? {
+ val flags = cursor.getInt(COLUMN_FLAGS)
+ val supportsCreate = flags and FLAG_SUPPORTS_CREATE != 0
+ val supportsIsChild = flags and FLAG_SUPPORTS_IS_CHILD != 0
+ if (!supportsCreate || !supportsIsChild) return null
+ val rootId = cursor.getString(COLUMN_ROOT_ID)!!
+ if (authority == AUTHORITY_STORAGE && rootId == ROOT_ID_HOME) return null
+ val supportsEject = flags and FLAG_SUPPORTS_EJECT != 0
+ return StorageRoot(
+ authority = authority,
+ rootId = rootId,
+ documentId = cursor.getString(COLUMN_DOCUMENT_ID)!!,
+ icon = getIcon(context, authority, rootId, cursor.getInt(COLUMN_ICON)),
+ title = cursor.getString(COLUMN_TITLE)!!,
+ summary = cursor.getString(COLUMN_SUMMARY),
+ availableBytes = cursor.getLong(COLUMN_AVAILABLE_BYTES),
+ supportsEject = supportsEject
+ )
+ }
+
+ private fun checkOrAddUsbRoot(roots: ArrayList) {
+ for (root in roots) {
+ if (root.authority == AUTHORITY_STORAGE && root.supportsEject) return
+ }
+ val root = StorageRoot(
+ authority = AUTHORITY_STORAGE,
+ rootId = "usb",
+ documentId = "fake",
+ icon = getIcon(context, AUTHORITY_STORAGE, "usb", 0),
+ title = context.getString(R.string.storage_fake_drive_title),
+ summary = context.getString(R.string.storage_fake_drive_summary),
+ availableBytes = null,
+ supportsEject = true,
+ enabled = false
+ )
+ roots.add(root)
+ }
+
+ private fun ProviderInfo.isSupported(): Boolean {
+ return if (!exported) {
+ Log.w(TAG, "Provider is not exported")
+ false
+ } else if (!grantUriPermissions) {
+ Log.w(TAG, "Provider doesn't grantUriPermissions")
+ false
+ } else if (MANAGE_DOCUMENTS != readPermission || MANAGE_DOCUMENTS != writePermission) {
+ Log.w(TAG, "Provider is not protected by MANAGE_DOCUMENTS")
+ false
+ } else if (authority == AUTHORITY_DOWNLOADS) {
+ Log.w(TAG, "Not supporting $AUTHORITY_DOWNLOADS")
+ false
+ } else true
+ }
+
+ private fun Cursor.getString(columnName: String): String? {
+ val index = getColumnIndex(columnName)
+ return if (index != -1) getString(index) else null
+ }
+
+ private fun Cursor.getInt(columnName: String): Int {
+ val index = getColumnIndex(columnName)
+ return if (index != -1) getInt(index) else 0
+ }
+
+ private fun Cursor.getLong(columnName: String): Long? {
+ val index = getColumnIndex(columnName)
+ if (index == -1) return null
+ val value = getString(index) ?: return null
+ return try {
+ parseLong(value)
+ } catch (e: NumberFormatException) {
+ null
+ }
+ }
+
+ private fun getIcon(context: Context, authority: String, rootId: String, icon: Int): Drawable? {
+ return getPackageIcon(context, authority, icon) ?: when {
+ authority == AUTHORITY_STORAGE && rootId == ROOT_ID_DEVICE -> context.getDrawable(R.drawable.ic_phone_android)
+ authority == AUTHORITY_STORAGE && rootId != ROOT_ID_HOME -> context.getDrawable(R.drawable.ic_usb)
+ else -> null
+ }
+ }
+
+ private fun getPackageIcon(context: Context, authority: String?, icon: Int): Drawable? {
+ if (icon != 0) {
+ val pm = context.packageManager
+ val info = pm.resolveContentProvider(authority, 0)
+ if (info != null) {
+ return pm.getDrawable(info.packageName, icon, info.applicationInfo)
+ }
+ }
+ return null
+ }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageRootsFragment.kt b/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageRootsFragment.kt
new file mode 100644
index 00000000..81c5673d
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageRootsFragment.kt
@@ -0,0 +1,94 @@
+package com.stevesoltys.backup.ui.storage
+
+import android.content.Intent
+import android.content.Intent.*
+import android.os.Bundle
+import android.provider.DocumentsContract
+import android.view.LayoutInflater
+import android.view.View
+import android.view.View.INVISIBLE
+import android.view.View.VISIBLE
+import android.view.ViewGroup
+import androidx.appcompat.app.AppCompatActivity.RESULT_OK
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.Observer
+import androidx.lifecycle.ViewModelProviders
+import com.stevesoltys.backup.R
+import com.stevesoltys.backup.ui.INTENT_EXTRA_IS_RESTORE
+import com.stevesoltys.backup.ui.REQUEST_CODE_OPEN_DOCUMENT_TREE
+import kotlinx.android.synthetic.main.fragment_storage_root.*
+
+private val TAG = StorageRootsFragment::class.java.simpleName
+
+internal class StorageRootsFragment : Fragment(), StorageRootClickedListener {
+
+ companion object {
+ fun newInstance(isRestore: Boolean): StorageRootsFragment {
+ val f = StorageRootsFragment()
+ f.arguments = Bundle().apply {
+ putBoolean(INTENT_EXTRA_IS_RESTORE, isRestore)
+ }
+ return f
+ }
+ }
+
+ private lateinit var viewModel: StorageViewModel
+
+ private val adapter by lazy { StorageRootAdapter(viewModel.isRestoreOperation, this) }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?): View? {
+ return inflater.inflate(R.layout.fragment_storage_root, container, false)
+ }
+
+ override fun onActivityCreated(savedInstanceState: Bundle?) {
+ super.onActivityCreated(savedInstanceState)
+
+ viewModel = if (arguments!!.getBoolean(INTENT_EXTRA_IS_RESTORE)) {
+ ViewModelProviders.of(requireActivity()).get(RestoreStorageViewModel::class.java)
+ } else {
+ ViewModelProviders.of(requireActivity()).get(BackupStorageViewModel::class.java)
+ }
+
+ if (viewModel.isRestoreOperation) {
+ titleView.text = getString(R.string.storage_fragment_restore_title)
+ backView.visibility = VISIBLE
+ backView.setOnClickListener { requireActivity().finishAfterTransition() }
+ }
+
+ listView.adapter = adapter
+
+ viewModel.storageRoots.observe(this, Observer { roots -> onRootsLoaded(roots) })
+ }
+
+ override fun onStart() {
+ super.onStart()
+ viewModel.loadStorageRoots()
+ }
+
+ private fun onRootsLoaded(roots: List) {
+ progressBar.visibility = INVISIBLE
+ adapter.setItems(roots)
+ }
+
+ override fun onClick(root: StorageRoot) {
+ val intent = Intent(requireContext(), PermissionGrantActivity::class.java)
+ intent.data = DocumentsContract.buildTreeDocumentUri(root.authority, root.documentId)
+ intent.addFlags(FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
+ FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION)
+ startActivityForResult(intent, REQUEST_CODE_OPEN_DOCUMENT_TREE)
+ }
+
+ override fun onActivityResult(requestCode: Int, resultCode: Int, result: Intent?) {
+ if (resultCode == RESULT_OK && requestCode == REQUEST_CODE_OPEN_DOCUMENT_TREE) {
+ viewModel.onUriPermissionGranted(result)
+ } else {
+ super.onActivityResult(requestCode, resultCode, result)
+ }
+ }
+
+}
+
+internal interface StorageRootClickedListener {
+ fun onClick(root: StorageRoot)
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageViewModel.kt b/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageViewModel.kt
new file mode 100644
index 00000000..da700715
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageViewModel.kt
@@ -0,0 +1,76 @@
+package com.stevesoltys.backup.ui.storage
+
+import android.app.Application
+import android.content.Context
+import android.content.Intent
+import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
+import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+import android.net.Uri
+import androidx.documentfile.provider.DocumentFile
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import com.stevesoltys.backup.isOnExternalStorage
+import com.stevesoltys.backup.settings.getBackupFolderUri
+import com.stevesoltys.backup.ui.LiveEvent
+import com.stevesoltys.backup.ui.MutableLiveEvent
+
+internal abstract class StorageViewModel(private val app: Application) : AndroidViewModel(app), RemovableStorageListener {
+
+ private val mStorageRoots = MutableLiveData>()
+ internal val storageRoots: LiveData> get() = mStorageRoots
+
+ private val mLocationSet = MutableLiveEvent()
+ internal val locationSet: LiveEvent get() = mLocationSet
+
+ protected val mLocationChecked = MutableLiveEvent()
+ internal val locationChecked: LiveEvent get() = mLocationChecked
+
+ private val storageRootFetcher by lazy { StorageRootFetcher(app) }
+
+ abstract val isRestoreOperation: Boolean
+
+ companion object {
+ internal fun validLocationIsSet(context: Context): Boolean {
+ val uri = getBackupFolderUri(context) ?: return false
+ if (uri.isOnExternalStorage()) return true // TODO use ejectable instead
+ val file = DocumentFile.fromTreeUri(context, uri) ?: return false
+ return file.isDirectory
+ }
+ }
+
+ internal fun loadStorageRoots() {
+ if (storageRootFetcher.getRemovableStorageListener() == null) {
+ storageRootFetcher.setRemovableStorageListener(this)
+ }
+ Thread {
+ mStorageRoots.postValue(storageRootFetcher.getStorageRoots())
+ }.start()
+ }
+
+ override fun onStorageChanged() = loadStorageRoots()
+
+
+ internal fun onUriPermissionGranted(result: Intent?) {
+ val uri = result?.data ?: return
+
+ // inform UI that a location has been successfully selected
+ mLocationSet.setEvent(true)
+
+ // persist permission to access backup folder across reboots
+ val takeFlags = result.flags and (FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION)
+ app.contentResolver.takePersistableUriPermission(uri, takeFlags)
+
+ onLocationSet(uri)
+ }
+
+ abstract fun onLocationSet(uri: Uri)
+
+ override fun onCleared() {
+ storageRootFetcher.setRemovableStorageListener(null)
+ super.onCleared()
+ }
+
+}
+
+class LocationResult(val errorMsg: String? = null)
diff --git a/app/src/main/res/drawable/ic_usb.xml b/app/src/main/res/drawable/ic_usb.xml
new file mode 100644
index 00000000..34ac6149
--- /dev/null
+++ b/app/src/main/res/drawable/ic_usb.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/layout/fragment_recovery_code_input.xml b/app/src/main/res/layout/fragment_recovery_code_input.xml
index b478d509..494a9166 100644
--- a/app/src/main/res/layout/fragment_recovery_code_input.xml
+++ b/app/src/main/res/layout/fragment_recovery_code_input.xml
@@ -9,7 +9,7 @@
+ tools:context=".ui.recoverycode.RecoveryCodeInputFragment">
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_storage_root.xml b/app/src/main/res/layout/fragment_storage_root.xml
new file mode 100644
index 00000000..b814c8e2
--- /dev/null
+++ b/app/src/main/res/layout/fragment_storage_root.xml
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/list_item_storage_root.xml b/app/src/main/res/layout/list_item_storage_root.xml
new file mode 100644
index 00000000..e9839533
--- /dev/null
+++ b/app/src/main/res/layout/list_item_storage_root.xml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 5ab5803c..4cd909e0 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,4 +1,4 @@
-
+
Backup
Create backup
@@ -28,7 +28,6 @@
Backup location
Choose backup location
Backup Location
- Choose where to store your backups. More options might get added in the future.
The chosen location can not be used.
External Storage
All backups are encrypted on your phone. To restore from backup you will need your 12-word recovery code.
@@ -36,6 +35,18 @@
When reinstalling an app, restore backed up settings and data
Backup now
+
+ Choose where to store backups
+ Where to find your backups?
+ USB Flash Drive
+ Needs to be plugged in
+ %1$s free
+ Initializing backup location…
+ Looking for backups…
+ An error occurred while accessing the backup location.
+ Unable to get the permission to write to the backup location.
+ Back
+
Recovery Code
You need your 12-word recovery code to restore backed up data.
@@ -57,7 +68,7 @@
Word 12
You forgot to enter this word.
Wrong word. Did you mean %1$s or %2$s?
- We are so sorry! An unexpected error occurred.
+ Your code is invalid. Please check all words and try again!
Backup Notification
@@ -85,5 +96,9 @@
An error occurred while restoring the backup.
Note that we could only restore data for apps that are already installed.\n\nWhen you install more apps, we will try to restore their data and settings from this backup. So please do not delete it as long as it might still be needed.
Finish
+ Warning
+ You have chosen internal storage for your backup. This will not be available when your phone is lost or broken.
+ Choose Other
+ Use anyway
diff --git a/app/src/main/res/xml/backup_location.xml b/app/src/main/res/xml/backup_location.xml
deleted file mode 100644
index 1c8abbe6..00000000
--- a/app/src/main/res/xml/backup_location.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-