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