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 @@ + + + + + + + + + + + + + + + +