diff --git a/Android.mk b/Android.mk
index ed863870..a97002fe 100644
--- a/Android.mk
+++ b/Android.mk
@@ -1,13 +1,5 @@
LOCAL_PATH := $(call my-dir)
-include $(CLEAR_VARS)
-LOCAL_MODULE := default-permissions_com.stevesoltys.backup.xml
-LOCAL_MODULE_CLASS := ETC
-LOCAL_MODULE_TAGS := optional
-LOCAL_MODULE_PATH := $(TARGET_OUT_ETC)/default-permissions
-LOCAL_SRC_FILES := $(LOCAL_MODULE)
-include $(BUILD_PREBUILT)
-
include $(CLEAR_VARS)
LOCAL_MODULE := permissions_com.stevesoltys.backup.xml
LOCAL_MODULE_CLASS := ETC
@@ -38,4 +30,4 @@ LOCAL_MODULE_CLASS := APPS
LOCAL_PRIVILEGED_MODULE := true
LOCAL_DEX_PREOPT := false
LOCAL_REQUIRED_MODULES := permissions_com.stevesoltys.backup.xml whitelist_com.stevesoltys.backup.xml
-include $(BUILD_PREBUILT)
\ No newline at end of file
+include $(BUILD_PREBUILT)
diff --git a/app/build.gradle b/app/build.gradle
index 8fe39a43..a40be0de 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -106,10 +106,10 @@ dependencies {
implementation 'commons-io:commons-io:2.6'
implementation 'io.github.novacrypto:BIP39:2019.01.27'
- implementation 'androidx.core:core-ktx:1.0.2'
- implementation 'androidx.preference:preference-ktx:1.0.0'
+ implementation 'androidx.core:core-ktx:1.1.0'
+ implementation 'androidx.preference:preference-ktx:1.1.0'
implementation 'com.google.android.material:material:1.0.0'
- implementation 'androidx.lifecycle:lifecycle-extensions:2.0.0'
+ implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
lintChecks 'com.github.thirdegg:lint-rules:0.0.4-alpha'
diff --git a/app/src/androidTest/java/com/stevesoltys/backup/DocumentsStorageTest.kt b/app/src/androidTest/java/com/stevesoltys/backup/DocumentsStorageTest.kt
index 96b89f71..43786866 100644
--- a/app/src/androidTest/java/com/stevesoltys/backup/DocumentsStorageTest.kt
+++ b/app/src/androidTest/java/com/stevesoltys/backup/DocumentsStorageTest.kt
@@ -3,7 +3,8 @@ package com.stevesoltys.backup
import androidx.documentfile.provider.DocumentFile
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.runner.AndroidJUnit4
-import com.stevesoltys.backup.settings.getBackupFolderUri
+import com.stevesoltys.backup.settings.getBackupToken
+import com.stevesoltys.backup.settings.getStorage
import com.stevesoltys.backup.transport.backup.plugins.DocumentsStorage
import com.stevesoltys.backup.transport.backup.plugins.createOrGetFile
import org.junit.After
@@ -20,9 +21,9 @@ private const val filename = "test-file"
class DocumentsStorageTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
- private val folderUri = getBackupFolderUri(context)
- private val deviceName = "device name"
- private val storage = DocumentsStorage(context, folderUri, deviceName)
+ private val token = getBackupToken(context)
+ private val folderUri = getStorage(context)
+ private val storage = DocumentsStorage(context, folderUri, token)
private lateinit var file: DocumentFile
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 4665b8cb..ce44359f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -14,9 +14,10 @@
android:name="android.permission.BACKUP"
tools:ignore="ProtectedPermissions" />
-
-
+
+
+ android:name=".ui.storage.StorageActivity"
+ android:theme="@style/AppTheme.NoActionBar" />
+
+
+
+
+
+
+
+
+
+
+
VERSION) throw UnsupportedVersionException(version)
+ val metadataBytes = try {
+ crypto.decryptSegment(inputStream)
+ } catch (e: AEADBadTagException) {
+ throw DecryptionFailedException(e)
+ }
+ return decode(metadataBytes, version, expectedToken)
+ }
+
+ @VisibleForTesting
+ @Throws(SecurityException::class)
+ internal fun decode(bytes: ByteArray, expectedVersion: Byte, expectedToken: Long): BackupMetadata {
+ // NOTE: We don't do extensive validation of the parsed input here,
+ // because it was encrypted with authentication, so we should be able to trust it.
+ //
+ // However, it is important to ensure that the expected unauthenticated version and token
+ // matches the authenticated version and token in the JSON.
+ try {
+ val json = JSONObject(bytes.toString(Utf8))
+ val version = json.getInt(JSON_VERSION).toByte()
+ if (version != expectedVersion) {
+ throw SecurityException("Invalid version '${version.toInt()}' in metadata, expected '${expectedVersion.toInt()}'.")
+ }
+ val token = json.getLong(JSON_TOKEN)
+ if (token != expectedToken) {
+ throw SecurityException("Invalid token '$token' in metadata, expected '$expectedToken'.")
+ }
+ return BackupMetadata(
+ version = version,
+ token = token,
+ androidVersion = json.getInt(JSON_ANDROID_VERSION),
+ deviceName = json.getString(JSON_DEVICE_NAME)
+ )
+ } catch (e: JSONException) {
+ throw SecurityException(e)
+ }
+ }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/metadata/MetadataWriter.kt b/app/src/main/java/com/stevesoltys/backup/metadata/MetadataWriter.kt
new file mode 100644
index 00000000..2aa1f36b
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/metadata/MetadataWriter.kt
@@ -0,0 +1,36 @@
+package com.stevesoltys.backup.metadata
+
+import androidx.annotation.VisibleForTesting
+import com.stevesoltys.backup.Utf8
+import com.stevesoltys.backup.crypto.Crypto
+import org.json.JSONObject
+import java.io.IOException
+import java.io.OutputStream
+
+interface MetadataWriter {
+
+ @Throws(IOException::class)
+ fun write(outputStream: OutputStream, token: Long)
+
+}
+
+class MetadataWriterImpl(private val crypto: Crypto): MetadataWriter {
+
+ @Throws(IOException::class)
+ override fun write(outputStream: OutputStream, token: Long) {
+ val metadata = BackupMetadata(token = token)
+ outputStream.write(ByteArray(1).apply { this[0] = metadata.version })
+ crypto.encryptSegment(outputStream, encode(metadata))
+ }
+
+ @VisibleForTesting
+ internal fun encode(metadata: BackupMetadata): ByteArray {
+ val json = JSONObject()
+ json.put(JSON_VERSION, metadata.version.toInt())
+ json.put(JSON_TOKEN, metadata.token)
+ json.put(JSON_ANDROID_VERSION, metadata.androidVersion)
+ json.put(JSON_DEVICE_NAME, metadata.deviceName)
+ return json.toString().toByteArray(Utf8)
+ }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/restore/RestoreActivity.kt b/app/src/main/java/com/stevesoltys/backup/restore/RestoreActivity.kt
new file mode 100644
index 00000000..00f58cae
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/restore/RestoreActivity.kt
@@ -0,0 +1,47 @@
+package com.stevesoltys.backup.restore
+
+import android.os.Bundle
+import androidx.annotation.CallSuper
+import androidx.lifecycle.Observer
+import androidx.lifecycle.ViewModelProviders
+import com.stevesoltys.backup.R
+import com.stevesoltys.backup.ui.RequireProvisioningActivity
+import com.stevesoltys.backup.ui.RequireProvisioningViewModel
+
+class RestoreActivity : RequireProvisioningActivity() {
+
+ private lateinit var viewModel: RestoreViewModel
+
+ override fun getViewModel(): RequireProvisioningViewModel = viewModel
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ viewModel = ViewModelProviders.of(this).get(RestoreViewModel::class.java)
+ super.onCreate(savedInstanceState)
+
+ if (isSetupWizard) hideSystemUI()
+
+ setContentView(R.layout.activity_fragment_container)
+
+ viewModel.chosenRestoreSet.observe(this, Observer { set ->
+ if (set != null) showFragment(RestoreProgressFragment())
+ })
+
+ if (savedInstanceState == null) {
+ showFragment(RestoreSetFragment())
+ }
+ }
+
+ @CallSuper
+ override fun onStart() {
+ super.onStart()
+ if (isFinishing) return
+
+ // check that backup is provisioned
+ if (!viewModel.validLocationIsSet()) {
+ showStorageActivity()
+ } else if (!viewModel.recoveryCodeIsSet()) {
+ showRecoveryCodeActivity()
+ }
+ }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/restore/RestoreProgressFragment.kt b/app/src/main/java/com/stevesoltys/backup/restore/RestoreProgressFragment.kt
new file mode 100644
index 00000000..679c953a
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/restore/RestoreProgressFragment.kt
@@ -0,0 +1,69 @@
+package com.stevesoltys.backup.restore
+
+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 android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.Observer
+import androidx.lifecycle.ViewModelProviders
+import com.stevesoltys.backup.R
+import com.stevesoltys.backup.getAppName
+import com.stevesoltys.backup.isDebugBuild
+import com.stevesoltys.backup.settings.getStorage
+import kotlinx.android.synthetic.main.fragment_restore_progress.*
+
+class RestoreProgressFragment : Fragment() {
+
+ private lateinit var viewModel: RestoreViewModel
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?): View? {
+ return inflater.inflate(R.layout.fragment_restore_progress, container, false)
+ }
+
+ override fun onActivityCreated(savedInstanceState: Bundle?) {
+ super.onActivityCreated(savedInstanceState)
+
+ // decryption will fail when the device is locked, so keep the screen on to prevent locking
+ requireActivity().window.addFlags(FLAG_KEEP_SCREEN_ON)
+
+ viewModel = ViewModelProviders.of(requireActivity()).get(RestoreViewModel::class.java)
+
+ viewModel.chosenRestoreSet.observe(this, Observer { set ->
+ backupNameView.text = set.device
+ })
+
+ viewModel.restoreProgress.observe(this, Observer { currentPackage ->
+ val appName = getAppName(requireActivity().packageManager, currentPackage)
+ val displayName = if (isDebugBuild()) "$appName (${currentPackage})" else appName
+ currentPackageView.text = getString(R.string.restore_current_package, displayName)
+ })
+
+ viewModel.restoreFinished.observe(this, Observer { finished ->
+ progressBar.visibility = INVISIBLE
+ button.visibility = VISIBLE
+ if (finished == 0) {
+ // success
+ currentPackageView.text = getString(R.string.restore_finished_success)
+ warningView.text = if (getStorage(requireContext())?.ejectable == true) {
+ getString(R.string.restore_finished_warning_only_installed, getString(R.string.restore_finished_warning_ejectable))
+ } else {
+ getString(R.string.restore_finished_warning_only_installed, null)
+ }
+ warningView.visibility = VISIBLE
+ } else {
+ // error
+ currentPackageView.text = getString(R.string.restore_finished_error)
+ currentPackageView.setTextColor(warningView.textColors)
+ }
+ activity?.window?.clearFlags(FLAG_KEEP_SCREEN_ON)
+ })
+
+ button.setOnClickListener { requireActivity().finishAfterTransition() }
+ }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/restore/RestoreSetAdapter.kt b/app/src/main/java/com/stevesoltys/backup/restore/RestoreSetAdapter.kt
new file mode 100644
index 00000000..d6acf602
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/restore/RestoreSetAdapter.kt
@@ -0,0 +1,42 @@
+package com.stevesoltys.backup.restore
+
+import android.app.backup.RestoreSet
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.recyclerview.widget.RecyclerView.Adapter
+import androidx.recyclerview.widget.RecyclerView.ViewHolder
+import com.stevesoltys.backup.R
+import com.stevesoltys.backup.restore.RestoreSetAdapter.RestoreSetViewHolder
+
+internal class RestoreSetAdapter(
+ private val listener: RestoreSetClickListener,
+ private val items: Array) : Adapter() {
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RestoreSetViewHolder {
+ val v = LayoutInflater.from(parent.context)
+ .inflate(R.layout.list_item_restore_set, parent, false) as View
+ return RestoreSetViewHolder(v)
+ }
+
+ override fun getItemCount() = items.size
+
+ override fun onBindViewHolder(holder: RestoreSetViewHolder, position: Int) {
+ holder.bind(items[position])
+ }
+
+ inner class RestoreSetViewHolder(private val v: View) : ViewHolder(v) {
+
+ private val titleView = v.findViewById(R.id.titleView)
+ private val subtitleView = v.findViewById(R.id.subtitleView)
+
+ internal fun bind(item: RestoreSet) {
+ v.setOnClickListener { listener.onRestoreSetClicked(item) }
+ titleView.text = item.name
+ subtitleView.text = "Android Backup" // TODO change to backup date when available
+ }
+
+ }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/restore/RestoreSetFragment.kt b/app/src/main/java/com/stevesoltys/backup/restore/RestoreSetFragment.kt
new file mode 100644
index 00000000..39611868
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/restore/RestoreSetFragment.kt
@@ -0,0 +1,61 @@
+package com.stevesoltys.backup.restore
+
+import android.app.backup.RestoreSet
+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 androidx.lifecycle.Observer
+import androidx.lifecycle.ViewModelProviders
+import com.stevesoltys.backup.R
+import kotlinx.android.synthetic.main.fragment_restore_set.*
+
+class RestoreSetFragment : Fragment() {
+
+ private lateinit var viewModel: RestoreViewModel
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?): View? {
+ return inflater.inflate(R.layout.fragment_restore_set, container, false)
+ }
+
+ override fun onActivityCreated(savedInstanceState: Bundle?) {
+ super.onActivityCreated(savedInstanceState)
+ viewModel = ViewModelProviders.of(requireActivity()).get(RestoreViewModel::class.java)
+
+ viewModel.restoreSets.observe(this, Observer { result -> onRestoreSetsLoaded(result) })
+
+ backView.setOnClickListener { requireActivity().finishAfterTransition() }
+ }
+
+ override fun onStart() {
+ super.onStart()
+ if (viewModel.recoveryCodeIsSet() && viewModel.validLocationIsSet()) {
+ viewModel.loadRestoreSets()
+ }
+ }
+
+ private fun onRestoreSetsLoaded(result: RestoreSetResult) {
+ if (result.hasError()) {
+ errorView.visibility = VISIBLE
+ listView.visibility = INVISIBLE
+ progressBar.visibility = INVISIBLE
+
+ errorView.text = result.errorMsg
+ } else {
+ errorView.visibility = INVISIBLE
+ listView.visibility = VISIBLE
+ progressBar.visibility = INVISIBLE
+
+ listView.adapter = RestoreSetAdapter(viewModel, result.sets)
+ }
+ }
+
+}
+
+internal interface RestoreSetClickListener {
+ fun onRestoreSetClicked(set: RestoreSet)
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/restore/RestoreViewModel.kt b/app/src/main/java/com/stevesoltys/backup/restore/RestoreViewModel.kt
new file mode 100644
index 00000000..eae78b24
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/restore/RestoreViewModel.kt
@@ -0,0 +1,148 @@
+package com.stevesoltys.backup.restore
+
+import android.app.Application
+import android.app.backup.IRestoreObserver
+import android.app.backup.IRestoreSession
+import android.app.backup.RestoreSet
+import android.util.Log
+import androidx.annotation.WorkerThread
+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.transport.TRANSPORT_ID
+import com.stevesoltys.backup.ui.RequireProvisioningViewModel
+
+private val TAG = RestoreViewModel::class.java.simpleName
+
+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()
+
+ private val mRestoreSets = MutableLiveData()
+ internal val restoreSets: LiveData get() = mRestoreSets
+
+ private val mChosenRestoreSet = MutableLiveData()
+ internal val chosenRestoreSet: LiveData get() = mChosenRestoreSet
+
+ private val mRestoreProgress = MutableLiveData()
+ internal val restoreProgress: LiveData get() = mRestoreProgress
+
+ private val mRestoreFinished = MutableLiveData()
+ // Zero on success; a nonzero error code if the restore operation as a whole failed.
+ internal val restoreFinished: LiveData get() = mRestoreFinished
+
+ internal fun loadRestoreSets() {
+ val session = this.session ?: backupManager.beginRestoreSession(null, TRANSPORT_ID)
+ this.session = session
+
+ if (session == null) {
+ Log.e(TAG, "beginRestoreSession() returned null session")
+ mRestoreSets.value = RestoreSetResult(app.getString(R.string.restore_set_error))
+ return
+ }
+ val observer = this.observer ?: RestoreObserver()
+ this.observer = observer
+
+ val setResult = session.getAvailableRestoreSets(observer, monitor)
+ if (setResult != 0) {
+ Log.e(TAG, "getAvailableRestoreSets() returned non-zero value")
+ mRestoreSets.value = RestoreSetResult(app.getString(R.string.restore_set_error))
+ return
+ }
+ }
+
+ override fun onRestoreSetClicked(set: RestoreSet) {
+ val session = this.session
+ check(session != null)
+ session.restoreAll(set.token, observer, monitor)
+
+ mChosenRestoreSet.value = set
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ endSession()
+ }
+
+ private fun endSession() {
+ session?.endRestoreSession()
+ session = null
+ observer = null
+ }
+
+ @WorkerThread
+ private inner class RestoreObserver : IRestoreObserver.Stub() {
+
+ /**
+ * Supply a list of the restore datasets available from the current transport.
+ * This method is invoked as a callback following the application's use of the
+ * [IRestoreSession.getAvailableRestoreSets] method.
+ *
+ * @param restoreSets An array of [RestoreSet] objects
+ * describing all of the available datasets that are candidates for restoring to
+ * the current device. If no applicable datasets exist, restoreSets will be null.
+ */
+ override fun restoreSetsAvailable(restoreSets: Array?) {
+ if (restoreSets == null || restoreSets.isEmpty()) {
+ mRestoreSets.postValue(RestoreSetResult(app.getString(R.string.restore_set_empty_result)))
+ } else {
+ mRestoreSets.postValue(RestoreSetResult(restoreSets))
+ }
+ }
+
+ /**
+ * The restore operation has begun.
+ *
+ * @param numPackages The total number of packages being processed in this restore operation.
+ */
+ override fun restoreStarting(numPackages: Int) {
+ // noop
+ }
+
+ /**
+ * An indication of which package is being restored currently,
+ * out of the total number provided in the [restoreStarting] callback.
+ * This method is not guaranteed to be called.
+ *
+ * @param nowBeingRestored The index, between 1 and the numPackages parameter
+ * to the [restoreStarting] callback, of the package now being restored.
+ * @param currentPackage The name of the package now being restored.
+ */
+ override fun onUpdate(nowBeingRestored: Int, currentPackage: String) {
+ // nowBeingRestored reporting is buggy, so don't use it
+ mRestoreProgress.postValue(currentPackage)
+ }
+
+ /**
+ * The restore operation has completed.
+ *
+ * @param result Zero on success; a nonzero error code if the restore operation
+ * as a whole failed.
+ */
+ override fun restoreFinished(result: Int) {
+ mRestoreFinished.postValue(result)
+ endSession()
+ }
+
+ }
+
+}
+
+internal class RestoreSetResult(
+ internal val sets: Array,
+ internal val errorMsg: String?) {
+
+ internal constructor(sets: Array) : this(sets, null)
+
+ internal constructor(errorMsg: String) : this(emptyArray(), errorMsg)
+
+ internal fun hasError(): Boolean = errorMsg != null
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/settings/BackupLocationFragment.kt b/app/src/main/java/com/stevesoltys/backup/settings/BackupLocationFragment.kt
deleted file mode 100644
index eab22e01..00000000
--- a/app/src/main/java/com/stevesoltys/backup/settings/BackupLocationFragment.kt
+++ /dev/null
@@ -1,61 +0,0 @@
-package com.stevesoltys.backup.settings
-
-import android.app.Activity.RESULT_OK
-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.lifecycle.ViewModelProviders
-import androidx.preference.Preference
-import androidx.preference.PreferenceFragmentCompat
-import com.stevesoltys.backup.R
-
-private val TAG = BackupLocationFragment::class.java.name
-
-class BackupLocationFragment : PreferenceFragmentCompat() {
-
- private lateinit var viewModel: SettingsViewModel
-
- override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
- setPreferencesFromResource(R.xml.backup_location, rootKey)
-
- requireActivity().setTitle(R.string.settings_backup_location_title)
-
- viewModel = ViewModelProviders.of(requireActivity()).get(SettingsViewModel::class.java)
-
- 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)
- startActivityForResult(documentChooser, REQUEST_CODE_OPEN_DOCUMENT_TREE)
- } catch (ex: ActivityNotFoundException) {
- Toast.makeText(requireContext(), "Please install a file manager.", LENGTH_LONG).show()
- }
- }
-
- override fun onActivityResult(requestCode: Int, resultCode: Int, result: Intent?) {
- if (resultCode == RESULT_OK && requestCode == REQUEST_CODE_OPEN_DOCUMENT_TREE) {
- viewModel.handleChooseFolderResult(result)
- } else {
- super.onActivityResult(requestCode, resultCode, result)
- }
- }
-
-}
diff --git a/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeActivity.kt b/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeActivity.kt
deleted file mode 100644
index 0e737f94..00000000
--- a/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeActivity.kt
+++ /dev/null
@@ -1,55 +0,0 @@
-package com.stevesoltys.backup.settings
-
-import android.os.Bundle
-import android.view.MenuItem
-import androidx.appcompat.app.AppCompatActivity
-import androidx.lifecycle.ViewModelProviders
-import com.stevesoltys.backup.LiveEventHandler
-import com.stevesoltys.backup.R
-
-class RecoveryCodeActivity : AppCompatActivity() {
-
- private lateinit var viewModel: RecoveryCodeViewModel
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- setContentView(R.layout.activity_recovery_code)
-
- viewModel = ViewModelProviders.of(this).get(RecoveryCodeViewModel::class.java)
- viewModel.confirmButtonClicked.observeEvent(this, LiveEventHandler { clicked ->
- if (clicked) {
- val tag = "Confirm"
- supportFragmentManager.beginTransaction()
- .replace(R.id.fragment, RecoveryCodeInputFragment(), tag)
- .addToBackStack(tag)
- .commit()
- }
- })
- viewModel.recoveryCodeSaved.observeEvent(this, LiveEventHandler { saved ->
- if (saved) {
- setResult(RESULT_OK)
- finishAfterTransition()
- }
- })
-
- supportActionBar!!.setDisplayHomeAsUpEnabled(true)
-
- if (savedInstanceState == null) {
- supportFragmentManager.beginTransaction()
- .add(R.id.fragment, RecoveryCodeOutputFragment(), "Code")
- .commit()
- }
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when {
- item.itemId == android.R.id.home -> {
- onBackPressed()
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
- }
-
-}
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 36f52c99..b9b0756e 100644
--- a/app/src/main/java/com/stevesoltys/backup/settings/SettingsActivity.kt
+++ b/app/src/main/java/com/stevesoltys/backup/settings/SettingsActivity.kt
@@ -1,53 +1,31 @@
package com.stevesoltys.backup.settings
-import android.content.Intent
import android.os.Bundle
-import android.util.Log
-import android.view.MenuItem
-import androidx.appcompat.app.AppCompatActivity
-import androidx.fragment.app.Fragment
+import androidx.annotation.CallSuper
import androidx.lifecycle.ViewModelProviders
import com.stevesoltys.backup.Backup
-import com.stevesoltys.backup.LiveEventHandler
import com.stevesoltys.backup.R
+import com.stevesoltys.backup.ui.RequireProvisioningActivity
+import com.stevesoltys.backup.ui.RequireProvisioningViewModel
-private val TAG = SettingsActivity::class.java.name
-
-const val REQUEST_CODE_OPEN_DOCUMENT_TREE = 1
-const val REQUEST_CODE_RECOVERY_CODE = 2
-
-class SettingsActivity : AppCompatActivity() {
+class SettingsActivity : RequireProvisioningActivity() {
private lateinit var viewModel: SettingsViewModel
+ override fun getViewModel(): RequireProvisioningViewModel = viewModel
+
override fun onCreate(savedInstanceState: Bundle?) {
+ viewModel = ViewModelProviders.of(this).get(SettingsViewModel::class.java)
super.onCreate(savedInstanceState)
- setContentView(R.layout.activity_settings)
-
- viewModel = ViewModelProviders.of(this).get(SettingsViewModel::class.java)
- viewModel.onLocationSet.observeEvent(this, LiveEventHandler { initialSetUp ->
- if (initialSetUp) showFragment(SettingsFragment())
- else supportFragmentManager.popBackStack()
- })
- viewModel.chooseBackupLocation.observeEvent(this, LiveEventHandler { show ->
- if (show) showFragment(BackupLocationFragment(), true)
- })
+ setContentView(R.layout.activity_fragment_container)
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
if (savedInstanceState == null) showFragment(SettingsFragment())
}
- override fun onActivityResult(requestCode: Int, resultCode: Int, result: Intent?) {
- if (resultCode != RESULT_OK) {
- Log.w(TAG, "Error in activity result: $requestCode")
- finishAfterTransition()
- } else {
- super.onActivityResult(requestCode, resultCode, result)
- }
- }
-
+ @CallSuper
override fun onStart() {
super.onStart()
if (isFinishing) return
@@ -56,30 +34,10 @@ class SettingsActivity : AppCompatActivity() {
if (!viewModel.recoveryCodeIsSet()) {
showRecoveryCodeActivity()
} else if (!viewModel.validLocationIsSet()) {
- showFragment(BackupLocationFragment())
+ showStorageActivity()
// remove potential error notifications
(application as Backup).notificationManager.onBackupErrorSeen()
}
}
- override fun onOptionsItemSelected(item: MenuItem): Boolean = when {
- item.itemId == android.R.id.home -> {
- onBackPressed()
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
-
- private fun showRecoveryCodeActivity() {
- val intent = Intent(this, RecoveryCodeActivity::class.java)
- startActivityForResult(intent, REQUEST_CODE_RECOVERY_CODE)
- }
-
- private fun showFragment(f: Fragment, addToBackStack: Boolean = false) {
- val fragmentTransaction = supportFragmentManager.beginTransaction()
- .replace(R.id.fragment, f)
- if (addToBackStack) fragmentTransaction.addToBackStack(null)
- fragmentTransaction.commit()
- }
-
}
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 e36627d7..ba8de843 100644
--- a/app/src/main/java/com/stevesoltys/backup/settings/SettingsFragment.kt
+++ b/app/src/main/java/com/stevesoltys/backup/settings/SettingsFragment.kt
@@ -1,6 +1,7 @@
package com.stevesoltys.backup.settings
import android.content.Context.BACKUP_SERVICE
+import android.content.Intent
import android.os.Bundle
import android.os.RemoteException
import android.provider.Settings
@@ -9,14 +10,14 @@ import android.util.Log
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
-import android.widget.Toast
-import android.widget.Toast.LENGTH_SHORT
import androidx.lifecycle.ViewModelProviders
+import androidx.preference.Preference
import androidx.preference.Preference.OnPreferenceChangeListener
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.TwoStatePreference
import com.stevesoltys.backup.Backup
import com.stevesoltys.backup.R
+import com.stevesoltys.backup.restore.RestoreActivity
private val TAG = SettingsFragment::class.java.name
@@ -28,6 +29,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
private lateinit var backup: TwoStatePreference
private lateinit var autoRestore: TwoStatePreference
+ private lateinit var backupLocation: Preference
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings, rootKey)
@@ -35,7 +37,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
viewModel = ViewModelProviders.of(requireActivity()).get(SettingsViewModel::class.java)
- backup = findPreference("backup") as TwoStatePreference
+ backup = findPreference("backup")!!
backup.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
val enabled = newValue as Boolean
try {
@@ -48,13 +50,13 @@ class SettingsFragment : PreferenceFragmentCompat() {
}
}
- val backupLocation = findPreference("backup_location")
+ backupLocation = findPreference("backup_location")!!
backupLocation.setOnPreferenceClickListener {
viewModel.chooseBackupLocation()
true
}
- autoRestore = findPreference("auto_restore") as TwoStatePreference
+ autoRestore = findPreference("auto_restore")!!
autoRestore.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
val enabled = newValue as Boolean
try {
@@ -84,6 +86,10 @@ class SettingsFragment : PreferenceFragmentCompat() {
val resolver = requireContext().contentResolver
autoRestore.isChecked = Settings.Secure.getInt(resolver, BACKUP_AUTO_RESTORE, 1) == 1
+
+ // TODO add time of last backup here
+ val storageName = getStorage(requireContext())?.name
+ backupLocation.summary = storageName ?: getString(R.string.settings_backup_location_none )
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
@@ -100,7 +106,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
true
}
item.itemId == R.id.action_restore -> {
- Toast.makeText(requireContext(), "Not yet implemented", LENGTH_SHORT).show()
+ startActivity(Intent(requireContext(), RestoreActivity::class.java))
true
}
else -> super.onOptionsItemSelected(item)
diff --git a/app/src/main/java/com/stevesoltys/backup/settings/SettingsManager.kt b/app/src/main/java/com/stevesoltys/backup/settings/SettingsManager.kt
index 8fad7489..70ec8db5 100644
--- a/app/src/main/java/com/stevesoltys/backup/settings/SettingsManager.kt
+++ b/app/src/main/java/com/stevesoltys/backup/settings/SettingsManager.kt
@@ -3,33 +3,54 @@ package com.stevesoltys.backup.settings
import android.content.Context
import android.net.Uri
import android.preference.PreferenceManager.getDefaultSharedPreferences
+import java.util.*
-private const val PREF_KEY_BACKUP_URI = "backupUri"
-private const val PREF_KEY_DEVICE_NAME = "deviceName"
+private const val PREF_KEY_STORAGE_URI = "storageUri"
+private const val PREF_KEY_STORAGE_NAME = "storageName"
+private const val PREF_KEY_STORAGE_EJECTABLE = "storageEjectable"
+private const val PREF_KEY_BACKUP_TOKEN = "backupToken"
private const val PREF_KEY_BACKUP_PASSWORD = "backupLegacyPassword"
-fun setBackupFolderUri(context: Context, uri: Uri) {
+data class Storage(
+ val uri: Uri,
+ val name: String,
+ val ejectable: Boolean
+)
+
+fun setStorage(context: Context, storage: Storage) {
getDefaultSharedPreferences(context)
.edit()
- .putString(PREF_KEY_BACKUP_URI, uri.toString())
+ .putString(PREF_KEY_STORAGE_URI, storage.uri.toString())
+ .putString(PREF_KEY_STORAGE_NAME, storage.name)
+ .putBoolean(PREF_KEY_STORAGE_EJECTABLE, storage.ejectable)
.apply()
}
-fun getBackupFolderUri(context: Context): Uri? {
- val uriStr = getDefaultSharedPreferences(context).getString(PREF_KEY_BACKUP_URI, null)
- ?: return null
- return Uri.parse(uriStr)
+fun getStorage(context: Context): Storage? {
+ val prefs = getDefaultSharedPreferences(context)
+ val uriStr = prefs.getString(PREF_KEY_STORAGE_URI, null) ?: return null
+ val uri = Uri.parse(uriStr)
+ val name = prefs.getString(PREF_KEY_STORAGE_NAME, null) ?: throw IllegalStateException()
+ val ejectable = prefs.getBoolean(PREF_KEY_STORAGE_EJECTABLE, false)
+ return Storage(uri, name, ejectable)
}
-fun setDeviceName(context: Context, name: String) {
+/**
+ * Generates and returns a new backup token while saving it as well.
+ * Subsequent calls to [getBackupToken] will return this new token once saved.
+ */
+fun getAndSaveNewBackupToken(context: Context): Long = Date().time.apply {
getDefaultSharedPreferences(context)
.edit()
- .putString(PREF_KEY_DEVICE_NAME, name)
+ .putLong(PREF_KEY_BACKUP_TOKEN, this)
.apply()
}
-fun getDeviceName(context: Context): String? {
- return getDefaultSharedPreferences(context).getString(PREF_KEY_DEVICE_NAME, null)
+/**
+ * Returns the current backup token or 0 if none exists.
+ */
+fun getBackupToken(context: Context): Long {
+ return getDefaultSharedPreferences(context).getLong(PREF_KEY_BACKUP_TOKEN, 0L)
}
@Deprecated("Replaced by KeyManager#getBackupKey()")
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 6840dabd..3959aa9f 100644
--- a/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt
+++ b/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt
@@ -1,67 +1,21 @@
package com.stevesoltys.backup.settings
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.util.Log
-import androidx.documentfile.provider.DocumentFile
-import androidx.lifecycle.AndroidViewModel
import com.stevesoltys.backup.Backup
-import com.stevesoltys.backup.LiveEvent
-import com.stevesoltys.backup.MutableLiveEvent
-import com.stevesoltys.backup.isOnExternalStorage
-import com.stevesoltys.backup.transport.ConfigurableBackupTransportService
+import com.stevesoltys.backup.R
import com.stevesoltys.backup.transport.requestBackup
+import com.stevesoltys.backup.ui.RequireProvisioningViewModel
private val TAG = SettingsViewModel::class.java.simpleName
-class SettingsViewModel(application: Application) : AndroidViewModel(application) {
+class SettingsViewModel(app: Application) : RequireProvisioningViewModel(app) {
- private val app = application
+ override val isRestoreOperation = false
- private val locationWasSet = MutableLiveEvent()
- /**
- * Will be set to true if this is the initial location.
- * It will be false if an existing location was changed.
- */
- internal val onLocationSet: LiveEvent = locationWasSet
-
- private val mChooseBackupLocation = MutableLiveEvent()
- internal val chooseBackupLocation: LiveEvent = mChooseBackupLocation
- internal fun chooseBackupLocation() = mChooseBackupLocation.setEvent(true)
-
- fun recoveryCodeIsSet() = Backup.keyManager.hasBackupKey()
-
- 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
+ fun backupNow() {
+ val nm = (app as Backup).notificationManager
+ nm.onBackupUpdate(app.getString(R.string.notification_backup_starting), 0, 1, true)
+ Thread { requestBackup(app) }.start()
}
- 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)
-
- // check if this is initial set-up or a later change
- val initialSetUp = !validLocationIsSet()
-
- // store backup folder location in settings
- setBackupFolderUri(app, folderUri)
-
- // notify the UI that the location has been set
- locationWasSet.setEvent(initialSetUp)
-
- // 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")
- }
-
- fun backupNow() = Thread { requestBackup(app) }.start()
-
}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.kt b/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.kt
index bfc32f0b..7b6e564c 100644
--- a/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.kt
+++ b/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.kt
@@ -12,7 +12,7 @@ import android.os.ParcelFileDescriptor
import android.util.Log
import com.stevesoltys.backup.settings.SettingsActivity
-const val DEFAULT_RESTORE_SET_TOKEN: Long = 1
+val TRANSPORT_ID: String = ConfigurableBackupTransport::class.java.name
private const val TRANSPORT_DIRECTORY_NAME = "com.stevesoltys.backup.transport.ConfigurableBackupTransport"
private val TAG = ConfigurableBackupTransport::class.java.simpleName
@@ -32,8 +32,7 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
}
override fun name(): String {
- // TODO: Make this class non-static in ConfigurableBackupTransportService and use Context and a ComponentName.
- return this.javaClass.name
+ return TRANSPORT_ID
}
override fun getTransportFlags(): Int {
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt b/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt
index 0346049c..b896594c 100644
--- a/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt
+++ b/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt
@@ -6,8 +6,10 @@ import com.stevesoltys.backup.crypto.CipherFactoryImpl
import com.stevesoltys.backup.crypto.CryptoImpl
import com.stevesoltys.backup.header.HeaderReaderImpl
import com.stevesoltys.backup.header.HeaderWriterImpl
-import com.stevesoltys.backup.settings.getBackupFolderUri
-import com.stevesoltys.backup.settings.getDeviceName
+import com.stevesoltys.backup.metadata.MetadataReaderImpl
+import com.stevesoltys.backup.metadata.MetadataWriterImpl
+import com.stevesoltys.backup.settings.getBackupToken
+import com.stevesoltys.backup.settings.getStorage
import com.stevesoltys.backup.transport.backup.BackupCoordinator
import com.stevesoltys.backup.transport.backup.FullBackup
import com.stevesoltys.backup.transport.backup.InputFactory
@@ -24,12 +26,14 @@ class PluginManager(context: Context) {
// We can think about using an injection framework such as Dagger to simplify this.
- private val storage = DocumentsStorage(context, getBackupFolderUri(context), getDeviceName(context)!!)
+ private val storage = DocumentsStorage(context, getStorage(context), getBackupToken(context))
private val headerWriter = HeaderWriterImpl()
private val headerReader = HeaderReaderImpl()
private val cipherFactory = CipherFactoryImpl(Backup.keyManager)
private val crypto = CryptoImpl(cipherFactory, headerWriter, headerReader)
+ private val metadataWriter = MetadataWriterImpl(crypto)
+ private val metadataReader = MetadataReaderImpl(crypto)
private val backupPlugin = DocumentsProviderBackupPlugin(storage, context.packageManager)
@@ -38,7 +42,7 @@ class PluginManager(context: Context) {
private val fullBackup = FullBackup(backupPlugin.fullBackupPlugin, inputFactory, headerWriter, crypto)
private val notificationManager = (context.applicationContext as Backup).notificationManager
- internal val backupCoordinator = BackupCoordinator(backupPlugin, kvBackup, fullBackup, notificationManager)
+ internal val backupCoordinator = BackupCoordinator(context, backupPlugin, kvBackup, fullBackup, metadataWriter, notificationManager)
private val restorePlugin = DocumentsProviderRestorePlugin(storage)
@@ -46,6 +50,6 @@ class PluginManager(context: Context) {
private val kvRestore = KVRestore(restorePlugin.kvRestorePlugin, outputFactory, headerReader, crypto)
private val fullRestore = FullRestore(restorePlugin.fullRestorePlugin, outputFactory, headerReader, crypto)
- internal val restoreCoordinator = RestoreCoordinator(restorePlugin, kvRestore, fullRestore)
+ internal val restoreCoordinator = RestoreCoordinator(context, restorePlugin, kvRestore, fullRestore, metadataReader)
}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupCoordinator.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupCoordinator.kt
index a4d357f3..c244e611 100644
--- a/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupCoordinator.kt
+++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupCoordinator.kt
@@ -2,10 +2,13 @@ package com.stevesoltys.backup.transport.backup
import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_OK
+import android.content.Context
import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import android.util.Log
import com.stevesoltys.backup.BackupNotificationManager
+import com.stevesoltys.backup.metadata.MetadataWriter
+import com.stevesoltys.backup.settings.getBackupToken
import java.io.IOException
private val TAG = BackupCoordinator::class.java.simpleName
@@ -15,9 +18,11 @@ private val TAG = BackupCoordinator::class.java.simpleName
* @author Torsten Grote
*/
class BackupCoordinator(
+ private val context: Context,
private val plugin: BackupPlugin,
private val kv: KVBackup,
private val full: FullBackup,
+ private val metadataWriter: MetadataWriter,
private val nm: BackupNotificationManager) {
private var calledInitialize = false
@@ -49,6 +54,7 @@ class BackupCoordinator(
Log.i(TAG, "Initialize Device!")
return try {
plugin.initializeDevice()
+ writeBackupMetadata(getBackupToken(context))
// [finishBackup] will only be called when we return [TRANSPORT_OK] here
// so we remember that we initialized successfully
calledInitialize = true
@@ -129,11 +135,11 @@ class BackupCoordinator(
fun finishBackup(): Int = when {
kv.hasState() -> {
- if (full.hasState()) throw IllegalStateException()
+ check(!full.hasState())
kv.finishBackup()
}
full.hasState() -> {
- if (kv.hasState()) throw IllegalStateException()
+ check(!kv.hasState())
full.finishBackup()
}
calledInitialize || calledClearBackupData -> {
@@ -144,4 +150,10 @@ class BackupCoordinator(
else -> throw IllegalStateException()
}
+ @Throws(IOException::class)
+ private fun writeBackupMetadata(token: Long) {
+ val outputStream = plugin.getMetadataOutputStream()
+ metadataWriter.write(outputStream, token)
+ }
+
}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupPlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupPlugin.kt
index b3d6f5dc..ae9b8ce4 100644
--- a/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupPlugin.kt
+++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupPlugin.kt
@@ -1,6 +1,7 @@
package com.stevesoltys.backup.transport.backup
import java.io.IOException
+import java.io.OutputStream
interface BackupPlugin {
@@ -14,6 +15,12 @@ interface BackupPlugin {
@Throws(IOException::class)
fun initializeDevice()
+ /**
+ * Returns an [OutputStream] for writing backup metadata.
+ */
+ @Throws(IOException::class)
+ fun getMetadataOutputStream(): OutputStream
+
/**
* Returns the package name of the app that provides the backend storage
* which is used for the current backup location.
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/KVBackup.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/KVBackup.kt
index ac7e43e2..05138e0a 100644
--- a/app/src/main/java/com/stevesoltys/backup/transport/backup/KVBackup.kt
+++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/KVBackup.kt
@@ -141,7 +141,7 @@ class KVBackup(
val base64Key = key.encodeBase64()
val dataSize = changeSet.dataSize
- // read and encrypt value
+ // read value
val value = if (dataSize >= 0) {
Log.v(TAG, " Delta operation key $key size $dataSize key64 $base64Key")
val bytes = ByteArray(dataSize)
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderBackupPlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderBackupPlugin.kt
index e15466c1..3c378b7e 100644
--- a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderBackupPlugin.kt
+++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderBackupPlugin.kt
@@ -5,6 +5,7 @@ import com.stevesoltys.backup.transport.backup.BackupPlugin
import com.stevesoltys.backup.transport.backup.FullBackupPlugin
import com.stevesoltys.backup.transport.backup.KVBackupPlugin
import java.io.IOException
+import java.io.OutputStream
class DocumentsProviderBackupPlugin(
private val storage: DocumentsStorage,
@@ -24,14 +25,22 @@ class DocumentsProviderBackupPlugin(
storage.rootBackupDir ?: throw IOException()
// create backup folders
- val kvDir = storage.defaultKvBackupDir
- val fullDir = storage.defaultFullBackupDir
+ val kvDir = storage.currentKvBackupDir
+ val fullDir = storage.currentFullBackupDir
// wipe existing data
+ storage.getSetDir()?.findFile(FILE_BACKUP_METADATA)?.delete()
kvDir?.deleteContents()
fullDir?.deleteContents()
}
+ @Throws(IOException::class)
+ override fun getMetadataOutputStream(): OutputStream {
+ val setDir = storage.getSetDir() ?: throw IOException()
+ val metadataFile = setDir.createOrGetFile(FILE_BACKUP_METADATA)
+ return storage.getOutputStream(metadataFile)
+ }
+
override val providerPackageName: String? by lazy {
val authority = storage.rootBackupDir?.uri?.authority ?: return@lazy null
val providerInfo = packageManager.resolveContentProvider(authority, 0) ?: return@lazy null
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderFullBackup.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderFullBackup.kt
index 2ef6149a..04790d7c 100644
--- a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderFullBackup.kt
+++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderFullBackup.kt
@@ -16,7 +16,7 @@ class DocumentsProviderFullBackup(
@Throws(IOException::class)
override fun getOutputStream(targetPackage: PackageInfo): OutputStream {
- val file = storage.defaultFullBackupDir?.createOrGetFile(targetPackage.packageName)
+ val file = storage.currentFullBackupDir?.createOrGetFile(targetPackage.packageName)
?: throw IOException()
return storage.getOutputStream(file)
}
@@ -25,7 +25,7 @@ class DocumentsProviderFullBackup(
override fun removeDataOfPackage(packageInfo: PackageInfo) {
val packageName = packageInfo.packageName
Log.i(TAG, "Deleting $packageName...")
- val file = storage.defaultFullBackupDir?.findFile(packageName) ?: return
+ val file = storage.currentFullBackupDir?.findFile(packageName) ?: return
if (!file.delete()) throw IOException("Failed to delete $packageName")
}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderKVBackup.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderKVBackup.kt
index 5e4b86d8..bee48a21 100644
--- a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderKVBackup.kt
+++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderKVBackup.kt
@@ -15,7 +15,7 @@ class DocumentsProviderKVBackup(private val storage: DocumentsStorage) : KVBacku
@Throws(IOException::class)
override fun hasDataForPackage(packageInfo: PackageInfo): Boolean {
- val packageFile = storage.defaultKvBackupDir?.findFile(packageInfo.packageName)
+ val packageFile = storage.currentKvBackupDir?.findFile(packageInfo.packageName)
?: return false
return packageFile.listFiles().isNotEmpty()
}
@@ -30,7 +30,7 @@ class DocumentsProviderKVBackup(private val storage: DocumentsStorage) : KVBacku
override fun removeDataOfPackage(packageInfo: PackageInfo) {
// we cannot use the cached this.packageFile here,
// because this can be called before [ensureRecordStorageForPackage]
- val packageFile = storage.defaultKvBackupDir?.findFile(packageInfo.packageName) ?: return
+ val packageFile = storage.currentKvBackupDir?.findFile(packageInfo.packageName) ?: return
packageFile.delete()
}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsStorage.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsStorage.kt
index 6cb16865..806a2163 100644
--- a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsStorage.kt
+++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsStorage.kt
@@ -2,33 +2,33 @@ package com.stevesoltys.backup.transport.backup.plugins
import android.content.Context
import android.content.pm.PackageInfo
-import android.net.Uri
import android.util.Log
import androidx.documentfile.provider.DocumentFile
-import com.stevesoltys.backup.transport.DEFAULT_RESTORE_SET_TOKEN
+import com.stevesoltys.backup.settings.Storage
+import com.stevesoltys.backup.settings.getAndSaveNewBackupToken
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
+const val DIRECTORY_ROOT = ".AndroidBackup"
const val DIRECTORY_FULL_BACKUP = "full"
const val DIRECTORY_KEY_VALUE_BACKUP = "kv"
-private const val ROOT_DIR_NAME = ".AndroidBackup"
-private const val NO_MEDIA = ".nomedia"
+const val FILE_BACKUP_METADATA = ".backup.metadata"
+const val FILE_NO_MEDIA = ".nomedia"
private const val MIME_TYPE = "application/octet-stream"
private val TAG = DocumentsStorage::class.java.simpleName
-class DocumentsStorage(context: Context, parentFolder: Uri?, deviceName: String) {
-
- private val contentResolver = context.contentResolver
+class DocumentsStorage(private val context: Context, storage: Storage?, token: Long) {
internal val rootBackupDir: DocumentFile? by lazy {
- val folderUri = parentFolder ?: return@lazy null
+ val folderUri = storage?.uri ?: return@lazy null
+ // [fromTreeUri] should only return null when SDK_INT < 21
val parent = DocumentFile.fromTreeUri(context, folderUri) ?: throw AssertionError()
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
- rootDir.createOrGetFile(NO_MEDIA)
+ rootDir.createOrGetFile(FILE_NO_MEDIA)
rootDir
} catch (e: IOException) {
Log.e(TAG, "Error creating root backup dir.", e)
@@ -36,73 +36,71 @@ class DocumentsStorage(context: Context, parentFolder: Uri?, deviceName: String)
}
}
- private val deviceDir: DocumentFile? by lazy {
+ private val currentToken: Long by lazy {
+ if (token != 0L) token
+ else getAndSaveNewBackupToken(context).apply {
+ Log.d(TAG, "Using a fresh backup token: $this")
+ }
+ }
+
+ private val currentSetDir: DocumentFile? by lazy {
+ val currentSetName = currentToken.toString()
try {
- rootBackupDir?.createOrGetDirectory(deviceName)
+ rootBackupDir?.createOrGetDirectory(currentSetName)
} catch (e: IOException) {
Log.e(TAG, "Error creating current restore set dir.", e)
null
}
}
- private val defaultSetDir: DocumentFile? by lazy {
- val currentSetName = DEFAULT_RESTORE_SET_TOKEN.toString()
+ val currentFullBackupDir: DocumentFile? by lazy {
try {
- deviceDir?.createOrGetDirectory(currentSetName)
- } catch (e: IOException) {
- Log.e(TAG, "Error creating current restore set dir.", e)
- null
- }
- }
-
- val defaultFullBackupDir: DocumentFile? by lazy {
- try {
- defaultSetDir?.createOrGetDirectory(DIRECTORY_FULL_BACKUP)
+ currentSetDir?.createOrGetDirectory(DIRECTORY_FULL_BACKUP)
} catch (e: IOException) {
Log.e(TAG, "Error creating full backup dir.", e)
null
}
}
- val defaultKvBackupDir: DocumentFile? by lazy {
+ val currentKvBackupDir: DocumentFile? by lazy {
try {
- defaultSetDir?.createOrGetDirectory(DIRECTORY_KEY_VALUE_BACKUP)
+ currentSetDir?.createOrGetDirectory(DIRECTORY_KEY_VALUE_BACKUP)
} catch (e: IOException) {
Log.e(TAG, "Error creating K/V backup dir.", e)
null
}
}
- private fun getSetDir(token: Long = DEFAULT_RESTORE_SET_TOKEN): DocumentFile? {
- if (token == DEFAULT_RESTORE_SET_TOKEN) return defaultSetDir
- return deviceDir?.findFile(token.toString())
+ fun getSetDir(token: Long = currentToken): DocumentFile? {
+ if (token == currentToken) return currentSetDir
+ return rootBackupDir?.findFile(token.toString())
}
- fun getKVBackupDir(token: Long = DEFAULT_RESTORE_SET_TOKEN): DocumentFile? {
- if (token == DEFAULT_RESTORE_SET_TOKEN) return defaultKvBackupDir ?: throw IOException()
+ fun getKVBackupDir(token: Long = currentToken): DocumentFile? {
+ if (token == currentToken) return currentKvBackupDir ?: throw IOException()
return getSetDir(token)?.findFile(DIRECTORY_KEY_VALUE_BACKUP)
}
@Throws(IOException::class)
- fun getOrCreateKVBackupDir(token: Long = DEFAULT_RESTORE_SET_TOKEN): DocumentFile {
- if (token == DEFAULT_RESTORE_SET_TOKEN) return defaultKvBackupDir ?: throw IOException()
+ fun getOrCreateKVBackupDir(token: Long = currentToken): DocumentFile {
+ if (token == currentToken) return currentKvBackupDir ?: throw IOException()
val setDir = getSetDir(token) ?: throw IOException()
return setDir.createOrGetDirectory(DIRECTORY_KEY_VALUE_BACKUP)
}
- fun getFullBackupDir(token: Long = DEFAULT_RESTORE_SET_TOKEN): DocumentFile? {
- if (token == DEFAULT_RESTORE_SET_TOKEN) return defaultFullBackupDir ?: throw IOException()
+ fun getFullBackupDir(token: Long = currentToken): DocumentFile? {
+ if (token == currentToken) return currentFullBackupDir ?: throw IOException()
return getSetDir(token)?.findFile(DIRECTORY_FULL_BACKUP)
}
@Throws(IOException::class)
fun getInputStream(file: DocumentFile): InputStream {
- return contentResolver.openInputStream(file.uri) ?: throw IOException()
+ return context.contentResolver.openInputStream(file.uri) ?: throw IOException()
}
@Throws(IOException::class)
fun getOutputStream(file: DocumentFile): OutputStream {
- return contentResolver.openOutputStream(file.uri) ?: throw IOException()
+ return context.contentResolver.openOutputStream(file.uri) ?: throw IOException()
}
}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/KVRestore.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/KVRestore.kt
index 4259998b..6ce3de67 100644
--- a/app/src/main/java/com/stevesoltys/backup/transport/restore/KVRestore.kt
+++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/KVRestore.kt
@@ -13,6 +13,7 @@ import com.stevesoltys.backup.header.UnsupportedVersionException
import libcore.io.IoUtils.closeQuietly
import java.io.IOException
import java.util.*
+import javax.crypto.AEADBadTagException
private class KVRestoreState(
internal val token: Long,
@@ -86,6 +87,9 @@ internal class KVRestore(
} catch (e: UnsupportedVersionException) {
Log.e(TAG, "Unsupported version in backup: ${e.version}", e)
TRANSPORT_ERROR
+ } catch (e: AEADBadTagException) {
+ Log.e(TAG, "Decryption failed", e)
+ TRANSPORT_ERROR
} finally {
this.state = null
closeQuietly(data)
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/RestoreCoordinator.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/RestoreCoordinator.kt
index d6dc397a..aa6334fb 100644
--- a/app/src/main/java/com/stevesoltys/backup/transport/restore/RestoreCoordinator.kt
+++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/RestoreCoordinator.kt
@@ -5,9 +5,15 @@ import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.RestoreDescription
import android.app.backup.RestoreDescription.*
import android.app.backup.RestoreSet
+import android.content.Context
import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import android.util.Log
+import com.stevesoltys.backup.header.UnsupportedVersionException
+import com.stevesoltys.backup.metadata.DecryptionFailedException
+import com.stevesoltys.backup.metadata.MetadataReader
+import com.stevesoltys.backup.settings.getBackupToken
+import libcore.io.IoUtils.closeQuietly
import java.io.IOException
private class RestoreCoordinatorState(
@@ -17,19 +23,59 @@ private class RestoreCoordinatorState(
private val TAG = RestoreCoordinator::class.java.simpleName
internal class RestoreCoordinator(
+ private val context: Context,
private val plugin: RestorePlugin,
private val kv: KVRestore,
- private val full: FullRestore) {
+ private val full: FullRestore,
+ private val metadataReader: MetadataReader) {
private var state: RestoreCoordinatorState? = null
+ /**
+ * Get the set of all backups currently available over this transport.
+ *
+ * @return Descriptions of the set of restore images available for this device,
+ * or null if an error occurred (the attempt should be rescheduled).
+ **/
fun getAvailableRestoreSets(): Array? {
- return plugin.getAvailableRestoreSets()
- .apply { Log.i(TAG, "Got available restore sets: $this") }
+ val availableBackups = plugin.getAvailableBackups() ?: return null
+ val restoreSets = ArrayList()
+ for (encryptedMetadata in availableBackups) {
+ if (encryptedMetadata.error) continue
+ check(encryptedMetadata.inputStream != null) // if there's no error, there must be a stream
+ try {
+ val metadata = metadataReader.readMetadata(encryptedMetadata.inputStream, encryptedMetadata.token)
+ val set = RestoreSet(metadata.deviceName, metadata.deviceName, metadata.token)
+ restoreSets.add(set)
+ } catch (e: IOException) {
+ Log.e(TAG, "Error while getting restore sets", e)
+ return null
+ } catch (e: SecurityException) {
+ Log.e(TAG, "Error while getting restore sets", e)
+ return null
+ } catch (e: DecryptionFailedException) {
+ Log.e(TAG, "Error while decrypting restore set", e)
+ continue
+ } catch (e: UnsupportedVersionException) {
+ Log.w(TAG, "Backup with unsupported version read", e)
+ continue
+ } finally {
+ closeQuietly(encryptedMetadata.inputStream)
+ }
+ }
+ Log.i(TAG, "Got available restore sets: $restoreSets")
+ return restoreSets.toTypedArray()
}
+ /**
+ * Get the identifying token of the backup set currently being stored from this device.
+ * This is used in the case of applications wishing to restore their last-known-good data.
+ *
+ * @return A token that can be used for restore,
+ * or 0 if there is no backup set available corresponding to the current device state.
+ */
fun getCurrentRestoreSet(): Long {
- return plugin.getCurrentRestoreSet()
+ return getBackupToken(context)
.apply { Log.i(TAG, "Got current restore set token: $this") }
}
@@ -46,7 +92,7 @@ internal class RestoreCoordinator(
* or [TRANSPORT_ERROR] (an error occurred, the restore should be aborted and rescheduled).
*/
fun startRestore(token: Long, packages: Array): Int {
- if (state != null) throw IllegalStateException()
+ check(state == null)
Log.i(TAG, "Start restore with ${packages.map { info -> info.packageName }}")
state = RestoreCoordinatorState(token, packages.iterator())
return TRANSPORT_OK
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/RestorePlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/RestorePlugin.kt
index 17f4f0ad..53094e07 100644
--- a/app/src/main/java/com/stevesoltys/backup/transport/restore/RestorePlugin.kt
+++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/RestorePlugin.kt
@@ -1,6 +1,6 @@
package com.stevesoltys.backup.transport.restore
-import android.app.backup.RestoreSet
+import com.stevesoltys.backup.metadata.EncryptedBackupMetadata
interface RestorePlugin {
@@ -11,18 +11,9 @@ interface RestorePlugin {
/**
* Get the set of all backups currently available for restore.
*
- * @return Descriptions of the set of restore images available for this device,
+ * @return metadata for the set of restore images available,
* or null if an error occurred (the attempt should be rescheduled).
**/
- fun getAvailableRestoreSets(): Array?
-
- /**
- * Get the identifying token of the backup set currently being stored from this device.
- * This is used in the case of applications wishing to restore their last-known-good data.
- *
- * @return A token that can be used for restore,
- * or 0 if there is no backup set available corresponding to the current device state.
- */
- fun getCurrentRestoreSet(): Long
+ fun getAvailableBackups(): Sequence?
}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderRestorePlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderRestorePlugin.kt
index deb9e327..5b852e20 100644
--- a/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderRestorePlugin.kt
+++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderRestorePlugin.kt
@@ -1,29 +1,72 @@
package com.stevesoltys.backup.transport.restore.plugins
-import android.app.backup.RestoreSet
-import com.stevesoltys.backup.transport.DEFAULT_RESTORE_SET_TOKEN
+import android.util.Log
+import androidx.documentfile.provider.DocumentFile
+import com.stevesoltys.backup.metadata.EncryptedBackupMetadata
import com.stevesoltys.backup.transport.backup.plugins.DocumentsStorage
+import com.stevesoltys.backup.transport.backup.plugins.FILE_BACKUP_METADATA
+import com.stevesoltys.backup.transport.backup.plugins.FILE_NO_MEDIA
import com.stevesoltys.backup.transport.restore.FullRestorePlugin
import com.stevesoltys.backup.transport.restore.KVRestorePlugin
import com.stevesoltys.backup.transport.restore.RestorePlugin
+import java.io.IOException
-class DocumentsProviderRestorePlugin(
- private val documentsStorage: DocumentsStorage) : RestorePlugin {
+private val TAG = DocumentsProviderRestorePlugin::class.java.simpleName
+
+class DocumentsProviderRestorePlugin(private val storage: DocumentsStorage) : RestorePlugin {
override val kvRestorePlugin: KVRestorePlugin by lazy {
- DocumentsProviderKVRestorePlugin(documentsStorage)
+ DocumentsProviderKVRestorePlugin(storage)
}
override val fullRestorePlugin: FullRestorePlugin by lazy {
- DocumentsProviderFullRestorePlugin(documentsStorage)
+ DocumentsProviderFullRestorePlugin(storage)
}
- override fun getAvailableRestoreSets(): Array? {
- return arrayOf(RestoreSet("default", "device", DEFAULT_RESTORE_SET_TOKEN))
+ override fun getAvailableBackups(): Sequence? {
+ val rootDir = storage.rootBackupDir ?: return null
+ 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)
+ }
+ }
}
- override fun getCurrentRestoreSet(): Long {
- return DEFAULT_RESTORE_SET_TOKEN
+ companion object {
+ fun getBackups(rootDir: DocumentFile): List {
+ val backupSets = ArrayList()
+ for (set in rootDir.listFiles()) {
+ if (!set.isDirectory || set.name == null) {
+ if (set.name != FILE_NO_MEDIA) {
+ Log.w(TAG, "Found invalid backup set folder: ${set.name}")
+ }
+ continue
+ }
+ val token = try {
+ set.name!!.toLong()
+ } catch (e: NumberFormatException) {
+ Log.w(TAG, "Found invalid backup set folder: ${set.name}", e)
+ continue
+ }
+ val metadata = set.findFile(FILE_BACKUP_METADATA)
+ if (metadata == null) {
+ Log.w(TAG, "Missing metadata file in backup set folder: ${set.name}")
+ } else {
+ backupSets.add(BackupSet(token, metadata))
+ }
+ }
+ return backupSets
+ }
}
}
+
+class BackupSet(val token: Long, val metadataFile: DocumentFile)
diff --git a/app/src/main/java/com/stevesoltys/backup/ui/BackupActivity.kt b/app/src/main/java/com/stevesoltys/backup/ui/BackupActivity.kt
new file mode 100644
index 00000000..0a23db24
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/ui/BackupActivity.kt
@@ -0,0 +1,34 @@
+package com.stevesoltys.backup.ui
+
+import android.view.MenuItem
+import android.view.View
+import androidx.annotation.CallSuper
+import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.Fragment
+import com.stevesoltys.backup.R
+
+abstract class BackupActivity : AppCompatActivity() {
+
+ @CallSuper
+ override fun onOptionsItemSelected(item: MenuItem): Boolean = when {
+ item.itemId == android.R.id.home -> {
+ onBackPressed()
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+
+ protected fun showFragment(f: Fragment, addToBackStack: Boolean = false) {
+ val fragmentTransaction = supportFragmentManager.beginTransaction()
+ .replace(R.id.fragment, f)
+ if (addToBackStack) fragmentTransaction.addToBackStack(null)
+ fragmentTransaction.commit()
+ }
+
+ protected fun hideSystemUI() {
+ window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE
+ or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
+ or View.SYSTEM_UI_FLAG_FULLSCREEN)
+ }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/LiveEvent.kt b/app/src/main/java/com/stevesoltys/backup/ui/LiveEvent.kt
similarity index 91%
rename from app/src/main/java/com/stevesoltys/backup/LiveEvent.kt
rename to app/src/main/java/com/stevesoltys/backup/ui/LiveEvent.kt
index 83aede27..b01d2840 100644
--- a/app/src/main/java/com/stevesoltys/backup/LiveEvent.kt
+++ b/app/src/main/java/com/stevesoltys/backup/ui/LiveEvent.kt
@@ -1,9 +1,9 @@
-package com.stevesoltys.backup
+package com.stevesoltys.backup.ui
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
-import com.stevesoltys.backup.LiveEvent.ConsumableEvent
+import com.stevesoltys.backup.ui.LiveEvent.ConsumableEvent
open class LiveEvent : LiveData>() {
diff --git a/app/src/main/java/com/stevesoltys/backup/LiveEventHandler.java b/app/src/main/java/com/stevesoltys/backup/ui/LiveEventHandler.java
similarity index 65%
rename from app/src/main/java/com/stevesoltys/backup/LiveEventHandler.java
rename to app/src/main/java/com/stevesoltys/backup/ui/LiveEventHandler.java
index 22d86af0..5070ddf4 100644
--- a/app/src/main/java/com/stevesoltys/backup/LiveEventHandler.java
+++ b/app/src/main/java/com/stevesoltys/backup/ui/LiveEventHandler.java
@@ -1,4 +1,4 @@
-package com.stevesoltys.backup;
+package com.stevesoltys.backup.ui;
public interface LiveEventHandler {
void onEvent(T t);
diff --git a/app/src/main/java/com/stevesoltys/backup/MutableLiveEvent.kt b/app/src/main/java/com/stevesoltys/backup/ui/MutableLiveEvent.kt
similarity index 86%
rename from app/src/main/java/com/stevesoltys/backup/MutableLiveEvent.kt
rename to app/src/main/java/com/stevesoltys/backup/ui/MutableLiveEvent.kt
index 7086bd40..ff279f1a 100644
--- a/app/src/main/java/com/stevesoltys/backup/MutableLiveEvent.kt
+++ b/app/src/main/java/com/stevesoltys/backup/ui/MutableLiveEvent.kt
@@ -1,4 +1,4 @@
-package com.stevesoltys.backup
+package com.stevesoltys.backup.ui
class MutableLiveEvent : LiveEvent() {
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..45913237
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/ui/RequireProvisioningActivity.kt
@@ -0,0 +1,72 @@
+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"
+const val INTENT_EXTRA_IS_SETUP_WIZARD = "isSetupWizard"
+
+private const val ACTION_SETUP_WIZARD = "com.stevesoltys.backup.restore.RESTORE_BACKUP"
+
+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 val isSetupWizard: Boolean
+ get() = intent?.action == ACTION_SETUP_WIZARD
+
+ 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)
+ intent.putExtra(INTENT_EXTRA_IS_SETUP_WIZARD, isSetupWizard)
+ startActivityForResult(intent, REQUEST_CODE_BACKUP_LOCATION)
+ }
+
+ protected fun showRecoveryCodeActivity() {
+ val intent = Intent(this, RecoveryCodeActivity::class.java)
+ intent.putExtra(INTENT_EXTRA_IS_RESTORE, getViewModel().isRestoreOperation)
+ intent.putExtra(INTENT_EXTRA_IS_SETUP_WIZARD, isSetupWizard)
+ startActivityForResult(intent, REQUEST_CODE_RECOVERY_CODE)
+ }
+
+}
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/recoverycode/RecoveryCodeActivity.kt b/app/src/main/java/com/stevesoltys/backup/ui/recoverycode/RecoveryCodeActivity.kt
new file mode 100644
index 00000000..8b2cec29
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/ui/recoverycode/RecoveryCodeActivity.kt
@@ -0,0 +1,73 @@
+package com.stevesoltys.backup.ui.recoverycode
+
+import android.os.Bundle
+import android.view.MenuItem
+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.INTENT_EXTRA_IS_SETUP_WIZARD
+import com.stevesoltys.backup.ui.LiveEventHandler
+
+class RecoveryCodeActivity : BackupActivity() {
+
+ private lateinit var viewModel: RecoveryCodeViewModel
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ if (isSetupWizard()) hideSystemUI()
+
+ setContentView(R.layout.activity_recovery_code)
+
+ viewModel = ViewModelProviders.of(this).get(RecoveryCodeViewModel::class.java)
+ viewModel.isRestore = isRestore()
+ viewModel.confirmButtonClicked.observeEvent(this, LiveEventHandler { clicked ->
+ if (clicked) showInput(true)
+ })
+ viewModel.recoveryCodeSaved.observeEvent(this, LiveEventHandler { saved ->
+ if (saved) {
+ setResult(RESULT_OK)
+ finishAfterTransition()
+ }
+ })
+
+ if (savedInstanceState == null) {
+ if (viewModel.isRestore) showInput(false)
+ else showOutput()
+ }
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ return when {
+ item.itemId == android.R.id.home -> {
+ onBackPressed()
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+ }
+
+ private fun showOutput() {
+ supportFragmentManager.beginTransaction()
+ .add(R.id.fragment, RecoveryCodeOutputFragment(), "Code")
+ .commit()
+ }
+
+ private fun showInput(addToBackStack: Boolean) {
+ val tag = "Confirm"
+ val fragmentTransaction = supportFragmentManager.beginTransaction()
+ .replace(R.id.fragment, RecoveryCodeInputFragment(), tag)
+ if (addToBackStack) fragmentTransaction.addToBackStack(tag)
+ fragmentTransaction.commit()
+ }
+
+ private fun isRestore(): Boolean {
+ return intent?.getBooleanExtra(INTENT_EXTRA_IS_RESTORE, false) ?: false
+ }
+
+ private fun isSetupWizard(): Boolean {
+ return intent?.getBooleanExtra(INTENT_EXTRA_IS_SETUP_WIZARD, false) ?: false
+ }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/settings/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/settings/RecoveryCodeAdapter.kt
rename to app/src/main/java/com/stevesoltys/backup/ui/recoverycode/RecoveryCodeAdapter.kt
index cc4e009c..9db18b10 100644
--- a/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeAdapter.kt
+++ b/app/src/main/java/com/stevesoltys/backup/ui/recoverycode/RecoveryCodeAdapter.kt
@@ -1,4 +1,4 @@
-package com.stevesoltys.backup.settings
+package com.stevesoltys.backup.ui.recoverycode
import android.view.LayoutInflater
import android.view.View
diff --git a/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeInputFragment.kt b/app/src/main/java/com/stevesoltys/backup/ui/recoverycode/RecoveryCodeInputFragment.kt
similarity index 77%
rename from app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeInputFragment.kt
rename to app/src/main/java/com/stevesoltys/backup/ui/recoverycode/RecoveryCodeInputFragment.kt
index 26918c49..2d2b87b8 100644
--- a/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeInputFragment.kt
+++ b/app/src/main/java/com/stevesoltys/backup/ui/recoverycode/RecoveryCodeInputFragment.kt
@@ -1,18 +1,21 @@
-package com.stevesoltys.backup.settings
+package com.stevesoltys.backup.ui.recoverycode
-import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.View.OnFocusChangeListener
import android.view.ViewGroup
+import android.widget.ArrayAdapter
+import android.widget.AutoCompleteTextView
import android.widget.Toast
import android.widget.Toast.LENGTH_LONG
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProviders
import com.stevesoltys.backup.R
+import com.stevesoltys.backup.isDebugBuild
import io.github.novacrypto.bip39.Validation.InvalidChecksumException
import io.github.novacrypto.bip39.Validation.WordNotFoundException
+import io.github.novacrypto.bip39.wordlists.English
import kotlinx.android.synthetic.main.fragment_recovery_code_input.*
import kotlinx.android.synthetic.main.recovery_code_input.*
@@ -29,15 +32,29 @@ class RecoveryCodeInputFragment : Fragment() {
super.onActivityCreated(savedInstanceState)
viewModel = ViewModelProviders.of(requireActivity()).get(RecoveryCodeViewModel::class.java)
+ if (viewModel.isRestore) introText.setText(R.string.recovery_code_input_intro)
+
+ val adapter = getAdapter()
+
for (i in 0 until WORD_NUM) {
val wordLayout = getWordLayout(i)
- wordLayout.editText!!.onFocusChangeListener = OnFocusChangeListener { _, focus ->
+ val editText = wordLayout.editText as AutoCompleteTextView
+ editText.onFocusChangeListener = OnFocusChangeListener { _, focus ->
if (!focus) wordLayout.isErrorEnabled = false
}
+ editText.setAdapter(adapter)
}
doneButton.setOnClickListener { done() }
- if (Build.TYPE == "userdebug") debugPreFill()
+ if (isDebugBuild() && !viewModel.isRestore) debugPreFill()
+ }
+
+ private fun getAdapter(): ArrayAdapter {
+ val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_list_item_1)
+ for (i in 0 until WORD_LIST_SIZE) {
+ adapter.add(English.INSTANCE.getWord(i))
+ }
+ return adapter
}
private fun getInput(): List = ArrayList(WORD_NUM).apply {
@@ -57,7 +74,7 @@ class RecoveryCodeInputFragment : Fragment() {
}
private fun allFilledOut(input: List): Boolean {
- for (i in 0 until input.size) {
+ for (i in input.indices) {
if (input[i].isNotEmpty()) continue
showError(i, getString(R.string.recovery_code_error_empty_word))
return false
@@ -96,7 +113,7 @@ class RecoveryCodeInputFragment : Fragment() {
private fun debugPreFill() {
val words = viewModel.wordList
- for (i in 0 until words.size) {
+ for (i in words.indices) {
getWordLayout(i).editText!!.setText(words[i])
}
}
diff --git a/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeOutputFragment.kt b/app/src/main/java/com/stevesoltys/backup/ui/recoverycode/RecoveryCodeOutputFragment.kt
similarity index 80%
rename from app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeOutputFragment.kt
rename to app/src/main/java/com/stevesoltys/backup/ui/recoverycode/RecoveryCodeOutputFragment.kt
index 724cb5a1..dcbb52e7 100644
--- a/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeOutputFragment.kt
+++ b/app/src/main/java/com/stevesoltys/backup/ui/recoverycode/RecoveryCodeOutputFragment.kt
@@ -1,6 +1,6 @@
-package com.stevesoltys.backup.settings
+package com.stevesoltys.backup.ui.recoverycode
-import android.content.res.Configuration
+import android.content.res.Configuration.ORIENTATION_LANDSCAPE
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@@ -33,12 +33,10 @@ class RecoveryCodeOutputFragment : Fragment() {
private fun setGridParameters(list: RecyclerView) {
val layoutManager = list.layoutManager as GridLayoutManager
- if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
- layoutManager.orientation = RecyclerView.VERTICAL
+ if (resources.configuration.orientation == ORIENTATION_LANDSCAPE) {
layoutManager.spanCount = 4
} else {
- layoutManager.orientation = RecyclerView.HORIZONTAL
- layoutManager.spanCount = 6
+ layoutManager.spanCount = 2
}
}
diff --git a/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeViewModel.kt b/app/src/main/java/com/stevesoltys/backup/ui/recoverycode/RecoveryCodeViewModel.kt
similarity index 90%
rename from app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeViewModel.kt
rename to app/src/main/java/com/stevesoltys/backup/ui/recoverycode/RecoveryCodeViewModel.kt
index 0083e9a0..a0bd0d0e 100644
--- a/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeViewModel.kt
+++ b/app/src/main/java/com/stevesoltys/backup/ui/recoverycode/RecoveryCodeViewModel.kt
@@ -1,10 +1,10 @@
-package com.stevesoltys.backup.settings
+package com.stevesoltys.backup.ui.recoverycode
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import com.stevesoltys.backup.Backup
-import com.stevesoltys.backup.LiveEvent
-import com.stevesoltys.backup.MutableLiveEvent
+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
@@ -15,6 +15,7 @@ import java.security.SecureRandom
import java.util.*
internal const val WORD_NUM = 12
+internal const val WORD_LIST_SIZE = 2048
class RecoveryCodeViewModel(application: Application) : AndroidViewModel(application) {
@@ -35,6 +36,8 @@ class RecoveryCodeViewModel(application: Application) : AndroidViewModel(applica
private val mRecoveryCodeSaved = MutableLiveEvent()
internal val recoveryCodeSaved: LiveEvent = mRecoveryCodeSaved
+ internal var isRestore: Boolean = false
+
@Throws(WordNotFoundException::class, InvalidChecksumException::class)
fun validateAndContinue(input: List) {
try {
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..16f15ed2
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/ui/storage/BackupStorageViewModel.kt
@@ -0,0 +1,56 @@
+package com.stevesoltys.backup.ui.storage
+
+import android.app.Application
+import android.app.backup.BackupProgress
+import android.app.backup.IBackupObserver
+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.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) {
+ saveStorage(uri)
+
+ // use a new backup token
+ getAndSaveNewBackupToken(app)
+
+ // 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..86b1f7b8
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/ui/storage/RestoreStorageViewModel.kt
@@ -0,0 +1,47 @@
+package com.stevesoltys.backup.ui.storage
+
+import android.app.Application
+import android.net.Uri
+import android.util.Log
+import androidx.documentfile.provider.DocumentFile
+import com.stevesoltys.backup.R
+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)) {
+ saveStorage(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..e4e50af0
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageActivity.kt
@@ -0,0 +1,99 @@
+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.INTENT_EXTRA_IS_SETUP_WIZARD
+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)
+
+ if (isSetupWizard()) hideSystemUI()
+
+ 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 isSetupWizard(): Boolean {
+ return intent?.getBooleanExtra(INTENT_EXTRA_IS_SETUP_WIZARD, 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..73d63f87
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageRootFetcher.kt
@@ -0,0 +1,212 @@
+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) {
+
+ internal val uri: Uri by lazy {
+ DocumentsContract.buildTreeDocumentUri(authority, documentId)
+ }
+
+ 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..8d66ee04
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageRootsFragment.kt
@@ -0,0 +1,98 @@
+package com.stevesoltys.backup.ui.storage
+
+import android.content.Intent
+import android.content.Intent.*
+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.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() }
+ } else {
+ warningIcon.visibility = VISIBLE
+ warningText.visibility = VISIBLE
+ divider.visibility = VISIBLE
+ }
+
+ 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) {
+ viewModel.onStorageRootChosen(root)
+ val intent = Intent(requireContext(), PermissionGrantActivity::class.java)
+ intent.data = root.uri
+ 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..1868f25b
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageViewModel.kt
@@ -0,0 +1,103 @@
+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 android.util.Log
+import androidx.documentfile.provider.DocumentFile
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import com.stevesoltys.backup.R
+import com.stevesoltys.backup.settings.Storage
+import com.stevesoltys.backup.settings.getStorage
+import com.stevesoltys.backup.settings.setStorage
+import com.stevesoltys.backup.transport.ConfigurableBackupTransportService
+import com.stevesoltys.backup.ui.LiveEvent
+import com.stevesoltys.backup.ui.MutableLiveEvent
+
+private val TAG = StorageViewModel::class.java.simpleName
+
+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) }
+ private var storageRoot: StorageRoot? = null
+
+ abstract val isRestoreOperation: Boolean
+
+ companion object {
+ internal fun validLocationIsSet(context: Context): Boolean {
+ val storage = getStorage(context) ?: return false
+ if (storage.ejectable) return true
+ val file = DocumentFile.fromTreeUri(context, storage.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()
+
+ fun onStorageRootChosen(root: StorageRoot) {
+ storageRoot = root
+ }
+
+ 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)
+
+ protected fun saveStorage(uri: Uri) {
+ // store backup storage location in settings
+ val root = storageRoot ?: throw IllegalStateException()
+ val name = if (root.isInternal()) {
+ "${root.title} (${app.getString(R.string.settings_backup_location_internal)})"
+ } else {
+ root.title
+ }
+ val storage = Storage(uri, name, root.supportsEject)
+ setStorage(app, storage)
+
+ // 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 saved: $uri")
+ }
+
+ override fun onCleared() {
+ storageRootFetcher.setRemovableStorageListener(null)
+ super.onCleared()
+ }
+
+}
+
+class LocationResult(val errorMsg: String? = null)
diff --git a/app/src/main/res/drawable/ic_cloud_download.xml b/app/src/main/res/drawable/ic_cloud_download.xml
new file mode 100644
index 00000000..6a3f5f94
--- /dev/null
+++ b/app/src/main/res/drawable/ic_cloud_download.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_phone_android.xml b/app/src/main/res/drawable/ic_phone_android.xml
new file mode 100644
index 00000000..c3cf49d9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_phone_android.xml
@@ -0,0 +1,10 @@
+
+
+
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/drawable/ic_warning.xml b/app/src/main/res/drawable/ic_warning.xml
new file mode 100644
index 00000000..55433d1c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_warning.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_fragment_container.xml
similarity index 100%
rename from app/src/main/res/layout/activity_settings.xml
rename to app/src/main/res/layout/activity_fragment_container.xml
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 1442389a..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_restore_progress.xml b/app/src/main/res/layout/fragment_restore_progress.xml
new file mode 100644
index 00000000..49983f4d
--- /dev/null
+++ b/app/src/main/res/layout/fragment_restore_progress.xml
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_restore_set.xml b/app/src/main/res/layout/fragment_restore_set.xml
new file mode 100644
index 00000000..d1e398e7
--- /dev/null
+++ b/app/src/main/res/layout/fragment_restore_set.xml
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_storage_check.xml b/app/src/main/res/layout/fragment_storage_check.xml
new file mode 100644
index 00000000..0694ab16
--- /dev/null
+++ b/app/src/main/res/layout/fragment_storage_check.xml
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..b139e9a0
--- /dev/null
+++ b/app/src/main/res/layout/fragment_storage_root.xml
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/list_item_recovery_code_output.xml b/app/src/main/res/layout/list_item_recovery_code_output.xml
index 661c2143..434def01 100644
--- a/app/src/main/res/layout/list_item_recovery_code_output.xml
+++ b/app/src/main/res/layout/list_item_recovery_code_output.xml
@@ -4,7 +4,7 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:padding="16dp"
+ android:padding="24dp"
tools:showIn="@layout/fragment_recovery_code_output">
+
+
+
+
+
+
+
+
+
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..92ff1a32
--- /dev/null
+++ b/app/src/main/res/layout/list_item_storage_root.xml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/recovery_code_input.xml b/app/src/main/res/layout/recovery_code_input.xml
index 03dae663..b6d122be 100644
--- a/app/src/main/res/layout/recovery_code_input.xml
+++ b/app/src/main/res/layout/recovery_code_input.xml
@@ -13,233 +13,244 @@
android:layout_height="wrap_content"
android:hint="@string/recovery_code_input_hint_1"
android:padding="16dp"
- app:layout_constraintBottom_toTopOf="@+id/wordLayout2"
- app:layout_constraintEnd_toStartOf="@+id/wordLayout7"
+ app:layout_constraintBottom_toTopOf="@+id/wordLayout3"
+ app:layout_constraintEnd_toStartOf="@+id/wordLayout2"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="spread_inside">
-
-
-
-
-
-
-
+ app:layout_constraintTop_toBottomOf="@+id/wordLayout1">
-
-
-
-
-
-
-
+ app:layout_constraintTop_toBottomOf="@+id/wordLayout3">
-
-
-
-
-
-
-
+ app:layout_constraintBottom_toTopOf="@+id/wordLayout9"
+ app:layout_constraintEnd_toStartOf="@+id/wordLayout2"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/wordLayout5">
-
-
-
-
-
-
-
+ app:layout_constraintBottom_toTopOf="@+id/wordLayout11"
+ app:layout_constraintEnd_toStartOf="@+id/wordLayout2"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/wordLayout7">
-
-
-
-
-
-
-
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toStartOf="@+id/wordLayout2"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/wordLayout9">
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ app:layout_constraintTop_toBottomOf="@+id/wordLayout10">
-
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index dce1cdd0..26a21938 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,19 +28,34 @@
Backup location
Choose backup location
Backup Location
- Choose where to store your backups. More options might get added in the future.
- External Storage
+ The chosen location can not be used.
+ None
+ Internal Storage
All backups are encrypted on your phone. To restore from backup you will need your 12-word recovery code.
Automatic restore
When reinstalling an app, restore backed up settings and data
Backup now
+
+ Choose where to store backups
+ Where to find your backups?
+ People with access to your storage location can learn which apps you use, but do not get access to the apps\' data.
+ 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.
Write it down on paper now!
Confirm Code
Enter your 12-word recovery code to ensure that it will work when you need it.
+ Enter your 12-word recovery code that you wrote down when setting up backups.
Done
Word 1
Word 2
@@ -56,11 +71,12 @@
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
Backup running
+ Starting Backup…
Backup complete
Not backed up
Backup failed
@@ -70,4 +86,24 @@
A device backup failed to run.
Fix
+
+ Restore from Backup
+ Choose a backup to restore
+ Don\'t restore
+ No backups found
+ We could not find any backups at this location.\n\nPlease choose another location that contains a %s folder.
+ An error occurred while loading the backups.
+ No suitable backups found at given location.\n\nThis is most likely due to a wrong recovery code or a storage error.
+ Restoring Backup
+ Restoring %s…
+ Restore complete.
+ 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.%s
+ \n\nPlease also ensure that the storage medium is plugged in when re-installing your apps.
+ 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/values/themes.xml b/app/src/main/res/values/themes.xml
index cd538f6f..96232824 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -4,4 +4,9 @@
- @style/PreferenceThemeOverlay
+
+
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 @@
-
-
-
-
-
diff --git a/app/src/main/res/xml/settings.xml b/app/src/main/res/xml/settings.xml
index 98f3f972..d19a6063 100644
--- a/app/src/main/res/xml/settings.xml
+++ b/app/src/main/res/xml/settings.xml
@@ -10,7 +10,7 @@
app:dependency="backup"
app:icon="@drawable/ic_storage"
app:key="backup_location"
- app:summary="@string/settings_backup_external_storage"
+ app:summary="@string/settings_backup_location_none"
app:title="@string/settings_backup_location" />
()
+
+ private val encoder = MetadataWriterImpl(crypto)
+ private val decoder = MetadataReaderImpl(crypto)
+
+ private val metadata = BackupMetadata(
+ version = 1.toByte(),
+ token = Random.nextLong(),
+ androidVersion = Random.nextInt(),
+ deviceName = getRandomString()
+ )
+ private val metadataByteArray = encoder.encode(metadata)
+
+ @Test
+ fun `unexpected version should throw SecurityException`() {
+ assertThrows(SecurityException::class.java) {
+ decoder.decode(metadataByteArray, 2.toByte(), metadata.token)
+ }
+ }
+
+ @Test
+ fun `unexpected token should throw SecurityException`() {
+ assertThrows(SecurityException::class.java) {
+ decoder.decode(metadataByteArray, metadata.version, metadata.token - 1)
+ }
+ }
+
+ @Test
+ fun `expected version and token do not throw SecurityException`() {
+ decoder.decode(metadataByteArray, metadata.version, metadata.token)
+ }
+
+ @Test
+ fun `malformed JSON throws SecurityException`() {
+ assertThrows(SecurityException::class.java) {
+ decoder.decode("{".toByteArray(Utf8), metadata.version, metadata.token)
+ }
+ }
+
+ @Test
+ fun `missing fields throws SecurityException`() {
+ val json = JSONObject()
+ json.put(JSON_VERSION, metadata.version.toInt())
+ json.put(JSON_TOKEN, metadata.token)
+ json.put(JSON_ANDROID_VERSION, metadata.androidVersion)
+ val jsonBytes = json.toString().toByteArray(Utf8)
+
+ assertThrows(SecurityException::class.java) {
+ decoder.decode(jsonBytes, metadata.version, metadata.token)
+ }
+ }
+
+}
diff --git a/app/src/test/java/com/stevesoltys/backup/metadata/MetadataWriterDecoderTest.kt b/app/src/test/java/com/stevesoltys/backup/metadata/MetadataWriterDecoderTest.kt
new file mode 100644
index 00000000..e2f315bb
--- /dev/null
+++ b/app/src/test/java/com/stevesoltys/backup/metadata/MetadataWriterDecoderTest.kt
@@ -0,0 +1,32 @@
+package com.stevesoltys.backup.metadata
+
+import com.stevesoltys.backup.crypto.Crypto
+import com.stevesoltys.backup.getRandomString
+import io.mockk.mockk
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.TestInstance
+import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS
+import kotlin.random.Random
+
+@TestInstance(PER_CLASS)
+internal class MetadataWriterDecoderTest {
+
+ private val crypto = mockk()
+
+ private val encoder = MetadataWriterImpl(crypto)
+ private val decoder = MetadataReaderImpl(crypto)
+
+ private val metadata = BackupMetadata(
+ version = Random.nextBytes(1)[0],
+ token = Random.nextLong(),
+ androidVersion = Random.nextInt(),
+ deviceName = getRandomString()
+ )
+
+ @Test
+ fun `encoded metadata matches decoded metadata`() {
+ assertEquals(metadata, decoder.decode(encoder.encode(metadata), metadata.version, metadata.token))
+ }
+
+}
diff --git a/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt
index 2f9ceb3c..3d4a48c4 100644
--- a/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt
+++ b/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt
@@ -14,6 +14,8 @@ import com.stevesoltys.backup.crypto.KeyManagerTestImpl
import com.stevesoltys.backup.encodeBase64
import com.stevesoltys.backup.header.HeaderReaderImpl
import com.stevesoltys.backup.header.HeaderWriterImpl
+import com.stevesoltys.backup.metadata.MetadataReaderImpl
+import com.stevesoltys.backup.metadata.MetadataWriterImpl
import com.stevesoltys.backup.transport.backup.*
import com.stevesoltys.backup.transport.restore.*
import io.mockk.*
@@ -32,6 +34,8 @@ internal class CoordinatorIntegrationTest : TransportTest() {
private val headerWriter = HeaderWriterImpl()
private val headerReader = HeaderReaderImpl()
private val cryptoImpl = CryptoImpl(cipherFactory, headerWriter, headerReader)
+ private val metadataWriter = MetadataWriterImpl(cryptoImpl)
+ private val metadataReader = MetadataReaderImpl(cryptoImpl)
private val backupPlugin = mockk()
private val kvBackupPlugin = mockk()
@@ -39,18 +43,18 @@ internal class CoordinatorIntegrationTest : TransportTest() {
private val fullBackupPlugin = mockk()
private val fullBackup = FullBackup(fullBackupPlugin, inputFactory, headerWriter, cryptoImpl)
private val notificationManager = mockk()
- private val backup = BackupCoordinator(backupPlugin, kvBackup, fullBackup, notificationManager)
+ private val backup = BackupCoordinator(context, backupPlugin, kvBackup, fullBackup, metadataWriter, notificationManager)
private val restorePlugin = mockk()
private val kvRestorePlugin = mockk()
private val kvRestore = KVRestore(kvRestorePlugin, outputFactory, headerReader, cryptoImpl)
private val fullRestorePlugin = mockk()
private val fullRestore = FullRestore(fullRestorePlugin, outputFactory, headerReader, cryptoImpl)
- private val restore = RestoreCoordinator(restorePlugin, kvRestore, fullRestore)
+ private val restore = RestoreCoordinator(context, restorePlugin, kvRestore, fullRestore, metadataReader)
private val backupDataInput = mockk()
private val fileDescriptor = mockk(relaxed = true)
- private val token = DEFAULT_RESTORE_SET_TOKEN
+ private val token = Random.nextLong()
private val appData = ByteArray(42).apply { Random.nextBytes(this) }
private val appData2 = ByteArray(1337).apply { Random.nextBytes(this) }
private val key = "RestoreKey"
diff --git a/app/src/test/java/com/stevesoltys/backup/transport/TransportTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/TransportTest.kt
index 6c425339..0241db5d 100644
--- a/app/src/test/java/com/stevesoltys/backup/transport/TransportTest.kt
+++ b/app/src/test/java/com/stevesoltys/backup/transport/TransportTest.kt
@@ -1,5 +1,6 @@
package com.stevesoltys.backup.transport
+import android.content.Context
import android.content.pm.PackageInfo
import android.util.Log
import com.stevesoltys.backup.crypto.Crypto
@@ -13,6 +14,7 @@ import org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD
abstract class TransportTest {
protected val crypto = mockk()
+ protected val context = mockk(relaxed = true)
protected val packageInfo = PackageInfo().apply { packageName = "org.example" }
diff --git a/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupCoordinatorTest.kt
index 8e8b38fb..97ac1bef 100644
--- a/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupCoordinatorTest.kt
+++ b/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupCoordinatorTest.kt
@@ -3,6 +3,7 @@ package com.stevesoltys.backup.transport.backup
import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_OK
import com.stevesoltys.backup.BackupNotificationManager
+import com.stevesoltys.backup.metadata.MetadataWriter
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
@@ -11,6 +12,7 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
import java.io.IOException
+import java.io.OutputStream
import kotlin.random.Random
internal class BackupCoordinatorTest: BackupTest() {
@@ -18,13 +20,17 @@ internal class BackupCoordinatorTest: BackupTest() {
private val plugin = mockk()
private val kv = mockk()
private val full = mockk()
+ private val metadataWriter = mockk()
private val notificationManager = mockk()
- private val backup = BackupCoordinator(plugin, kv, full, notificationManager)
+ private val backup = BackupCoordinator(context, plugin, kv, full, metadataWriter, notificationManager)
+
+ private val metadataOutputStream = mockk()
@Test
fun `device initialization succeeds and delegates to plugin`() {
every { plugin.initializeDevice() } just Runs
+ expectWritingMetadata(0L)
every { kv.hasState() } returns false
every { full.hasState() } returns false
@@ -110,4 +116,9 @@ internal class BackupCoordinatorTest: BackupTest() {
assertEquals(result, backup.finishBackup())
}
+ private fun expectWritingMetadata(token: Long = this.token) {
+ every { plugin.getMetadataOutputStream() } returns metadataOutputStream
+ every { metadataWriter.write(metadataOutputStream, token) } just Runs
+ }
+
}
diff --git a/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupTest.kt
index df96b38e..e179943b 100644
--- a/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupTest.kt
+++ b/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupTest.kt
@@ -1,11 +1,12 @@
package com.stevesoltys.backup.transport.backup
import android.os.ParcelFileDescriptor
-import com.stevesoltys.backup.transport.TransportTest
import com.stevesoltys.backup.header.HeaderWriter
import com.stevesoltys.backup.header.VersionHeader
+import com.stevesoltys.backup.transport.TransportTest
import io.mockk.mockk
import java.io.OutputStream
+import kotlin.random.Random
internal abstract class BackupTest : TransportTest() {
@@ -14,6 +15,7 @@ internal abstract class BackupTest : TransportTest() {
protected val data = mockk()
protected val outputStream = mockk()
+ protected val token = Random.nextLong()
protected val header = VersionHeader(packageName = packageInfo.packageName)
protected val quota = 42L
diff --git a/app/src/test/java/com/stevesoltys/backup/transport/restore/RestoreCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/restore/RestoreCoordinatorTest.kt
index 10aa6cb0..afd4df71 100644
--- a/app/src/test/java/com/stevesoltys/backup/transport/restore/RestoreCoordinatorTest.kt
+++ b/app/src/test/java/com/stevesoltys/backup/transport/restore/RestoreCoordinatorTest.kt
@@ -3,9 +3,12 @@ package com.stevesoltys.backup.transport.restore
import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.RestoreDescription
import android.app.backup.RestoreDescription.*
-import android.app.backup.RestoreSet
import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
+import com.stevesoltys.backup.getRandomString
+import com.stevesoltys.backup.metadata.BackupMetadata
+import com.stevesoltys.backup.metadata.EncryptedBackupMetadata
+import com.stevesoltys.backup.metadata.MetadataReader
import com.stevesoltys.backup.transport.TransportTest
import io.mockk.Runs
import io.mockk.every
@@ -14,6 +17,7 @@ import io.mockk.mockk
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import java.io.IOException
+import java.io.InputStream
import kotlin.random.Random
internal class RestoreCoordinatorTest : TransportTest() {
@@ -21,30 +25,39 @@ internal class RestoreCoordinatorTest : TransportTest() {
private val plugin = mockk()
private val kv = mockk()
private val full = mockk()
+ private val metadataReader = mockk()
- private val restore = RestoreCoordinator(plugin, kv, full)
+ private val restore = RestoreCoordinator(context, plugin, kv, full, metadataReader)
private val token = Random.nextLong()
+ private val inputStream = mockk()
private val packageInfo2 = PackageInfo().apply { packageName = "org.example2" }
private val packageInfoArray = arrayOf(packageInfo)
private val packageInfoArray2 = arrayOf(packageInfo, packageInfo2)
@Test
- fun `getAvailableRestoreSets() delegates to plugin`() {
- val restoreSets = Array(1) { RestoreSet() }
+ fun `getAvailableRestoreSets() builds set from plugin response`() {
+ val encryptedMetadata = EncryptedBackupMetadata(token, inputStream)
+ val metadata = BackupMetadata(
+ token = token,
+ androidVersion = Random.nextInt(),
+ deviceName = getRandomString())
- every { plugin.getAvailableRestoreSets() } returns restoreSets
+ every { plugin.getAvailableBackups() } returns sequenceOf(encryptedMetadata, encryptedMetadata)
+ every { metadataReader.readMetadata(inputStream, token) } returns metadata
+ every { inputStream.close() } just Runs
- assertEquals(restoreSets, restore.getAvailableRestoreSets())
+ val sets = restore.getAvailableRestoreSets() ?: fail()
+ assertEquals(2, sets.size)
+ assertEquals(metadata.deviceName, sets[0].device)
+ assertEquals(metadata.deviceName, sets[0].name)
+ assertEquals(metadata.token, sets[0].token)
}
@Test
fun `getCurrentRestoreSet() delegates to plugin`() {
- val currentRestoreSet = Random.nextLong()
-
- every { plugin.getCurrentRestoreSet() } returns currentRestoreSet
-
- assertEquals(currentRestoreSet, restore.getCurrentRestoreSet())
+ // We don't mock the SettingsManager, so the default value is returned here
+ assertEquals(0L, restore.getCurrentRestoreSet())
}
@Test
diff --git a/default-permissions_com.stevesoltys.backup.xml b/default-permissions_com.stevesoltys.backup.xml
deleted file mode 100644
index 825c0049..00000000
--- a/default-permissions_com.stevesoltys.backup.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-