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.
This commit is contained in:
Torsten Grote 2019-09-13 11:40:32 -03:00
parent 7455f4afb9
commit 6d8178f6b1
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
34 changed files with 1132 additions and 311 deletions

View file

@ -14,6 +14,11 @@
android:name="android.permission.BACKUP"
tools:ignore="ProtectedPermissions" />
<!-- This is needed to retrieve the available storage roots -->
<uses-permission
android:name="android.permission.MANAGE_DOCUMENTS"
tools:ignore="ProtectedPermissions" />
<application
android:name=".Backup"
android:allowBackup="false"
@ -28,8 +33,18 @@
android:exported="true" />
<activity
android:name=".ui.RecoveryCodeActivity"
android:label="@string/recovery_code_title" />
android:name=".ui.storage.StorageActivity"
android:theme="@style/AppTheme.NoActionBar" />
<activity
android:name=".ui.storage.PermissionGrantActivity"
android:exported="false"
android:permission="android.permission.MANAGE_DOCUMENTS" />
<activity
android:name=".ui.recoverycode.RecoveryCodeActivity"
android:label="@string/recovery_code_title"
android:theme="@style/AppTheme.NoActionBar" />
<activity
android:name=".restore.RestoreActivity"

View file

@ -8,8 +8,7 @@ import android.os.Build
import android.os.ServiceManager.getService
import com.stevesoltys.backup.crypto.KeyManager
import com.stevesoltys.backup.crypto.KeyManagerImpl
private const val URI_AUTHORITY_EXTERNAL_STORAGE = "com.android.externalstorage.documents"
import com.stevesoltys.backup.ui.storage.AUTHORITY_STORAGE
/**
* @author Steve Soltys
@ -32,6 +31,7 @@ class Backup : Application() {
}
fun Uri.isOnExternalStorage() = authority == URI_AUTHORITY_EXTERNAL_STORAGE
// TODO fix
fun Uri.isOnExternalStorage() = authority == AUTHORITY_STORAGE
fun isDebugBuild() = Build.TYPE == "userdebug"

View file

@ -9,6 +9,7 @@ import org.json.JSONException
import org.json.JSONObject
import java.io.IOException
import java.io.InputStream
import javax.crypto.AEADBadTagException
interface MetadataReader {
@ -24,7 +25,12 @@ class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
val version = inputStream.read().toByte()
if (version < 0) throw IOException()
if (version > 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)
}

View file

@ -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()
}
}

View file

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

View file

@ -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()
}

View file

@ -36,7 +36,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
viewModel = ViewModelProviders.of(requireActivity()).get(SettingsViewModel::class.java)
backup = findPreference("backup")!!
backup = findPreference<TwoStatePreference>("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<TwoStatePreference>("auto_restore")!!
autoRestore.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
val enabled = newValue as Boolean
try {

View file

@ -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))
}
}
}
}

View file

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

View file

@ -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()
}
}
}

View file

@ -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<LocationResult>()
/**
* 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<LocationResult> get() = mLocationSet
private val mChooseBackupLocation = MutableLiveEvent<Boolean>()
internal val chooseBackupLocation: LiveEvent<Boolean> 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)

View file

@ -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()
}
}

View file

@ -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<Boolean>()
internal val chooseBackupLocation: LiveEvent<Boolean> get() = mChooseBackupLocation
internal fun chooseBackupLocation() = mChooseBackupLocation.setEvent(true)
internal fun validLocationIsSet() = StorageViewModel.validLocationIsSet(app)
internal fun recoveryCodeIsSet() = Backup.keyManager.hasBackupKey()
}

View file

@ -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()

View file

@ -1,4 +1,4 @@
package com.stevesoltys.backup.ui
package com.stevesoltys.backup.ui.recoverycode
import android.view.LayoutInflater
import android.view.View

View file

@ -1,4 +1,4 @@
package com.stevesoltys.backup.ui
package com.stevesoltys.backup.ui.recoverycode
import android.os.Bundle
import android.view.LayoutInflater

View file

@ -1,4 +1,4 @@
package com.stevesoltys.backup.ui
package com.stevesoltys.backup.ui.recoverycode
import android.content.res.Configuration
import android.os.Bundle

View file

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

View file

@ -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))
}
}
}
}

View file

@ -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()
}
}

View file

@ -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()
}
}

View file

@ -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)
}
}

View file

@ -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() }
}
}
}

View file

@ -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<StorageRootViewHolder>() {
private val items = ArrayList<StorageRoot>()
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<StorageRoot>) {
this.items.clear()
this.items.addAll(items)
notifyDataSetChanged()
}
internal inner class StorageRootViewHolder(private val v: View) : ViewHolder(v) {
private val iconView = v.findViewById<ImageView>(R.id.iconView)
private val titleView = v.findViewById<TextView>(R.id.titleView)
private val summaryView = v.findViewById<TextView>(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()
}
}

View file

@ -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<StorageRoot> {
val roots = ArrayList<StorageRoot>()
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<StorageRoot> {
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<StorageRoot>()
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<StorageRoot>) {
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
}
}

View file

@ -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<StorageRoot>) {
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)
}

View file

@ -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<List<StorageRoot>>()
internal val storageRoots: LiveData<List<StorageRoot>> get() = mStorageRoots
private val mLocationSet = MutableLiveEvent<Boolean>()
internal val locationSet: LiveEvent<Boolean> get() = mLocationSet
protected val mLocationChecked = MutableLiveEvent<LocationResult>()
internal val locationChecked: LiveEvent<LocationResult> 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)

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?android:attr/textColorSecondary"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M15,7v4h1v2h-3V5h2l-3,-4 -3,4h2v8H8v-2.07c0.7,-0.37 1.2,-1.08 1.2,-1.93 0,-1.21 -0.99,-2.2 -2.2,-2.2 -1.21,0 -2.2,0.99 -2.2,2.2 0,0.85 0.5,1.56 1.2,1.93V13c0,1.11 0.89,2 2,2h3v3.05c-0.71,0.37 -1.2,1.1 -1.2,1.95 0,1.22 0.99,2.2 2.2,2.2 1.21,0 2.2,-0.98 2.2,-2.2 0,-0.85 -0.49,-1.58 -1.2,-1.95V15h3c1.11,0 2,-0.89 2,-2v-2h1V7h-4z" />
</vector>

View file

@ -9,7 +9,7 @@
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:context=".ui.RecoveryCodeInputFragment">
tools:context=".ui.recoverycode.RecoveryCodeInputFragment">
<ImageView
android:id="@+id/introIcon"

View file

@ -0,0 +1,76 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/imageView"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_margin="16dp"
android:tint="?android:colorAccent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_storage"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/titleView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:gravity="center_horizontal"
android:textSize="24sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/imageView"
tools:text="@string/storage_check_fragment_backup_title" />
<ProgressBar
android:id="@+id/progressBar"
style="?android:progressBarStyleLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="32dp"
android:layout_marginEnd="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/titleView"
app:layout_constraintVertical_bias="0.0" />
<TextView
android:id="@+id/errorView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="32dp"
android:layout_marginEnd="16dp"
android:textColor="@android:color/holo_red_dark"
android:textSize="18sp"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/titleView"
app:layout_constraintVertical_bias="0.0"
tools:text="@string/storage_check_fragment_backup_error"
tools:visibility="visible" />
<Button
android:id="@+id/backButton"
style="@style/Widget.AppCompat.Button.Colored"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:text="@string/storage_check_fragment_error_button"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/imageView"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_margin="16dp"
android:tint="?android:colorAccent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_storage"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/titleView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:text="@string/storage_fragment_backup_title"
android:textSize="24sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/imageView" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/listView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="16dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toTopOf="@+id/backView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/titleView"
tools:listitem="@layout/list_item_storage_root" />
<ProgressBar
android:id="@+id/progressBar"
style="?android:progressBarStyleLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toTopOf="@+id/backView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/titleView" />
<TextView
android:id="@+id/backView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:text="@string/restore_back"
android:textColor="?android:colorAccent"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/listView"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:selectableItemBackground">
<ImageView
android:id="@+id/iconView"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="ContentDescription"
tools:srcCompat="@tools:sample/avatars" />
<TextView
android:id="@+id/titleView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:textColor="?android:attr/textColorPrimary"
app:layout_constraintBottom_toTopOf="@+id/summaryView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toEndOf="@+id/iconView"
app:layout_constraintTop_toTopOf="parent"
app:layout_goneMarginBottom="16dp"
tools:text="SanDisk USB drive" />
<TextView
android:id="@+id/summaryView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="16dp"
android:textColor="?android:attr/textColorTertiary"
android:textSize="12sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="@+id/titleView"
app:layout_constraintTop_toBottomOf="@+id/titleView"
tools:text="31.99 GB free"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,4 +1,4 @@
<resources>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="app_name">Backup</string>
<string name="create_backup_button">Create backup</string>
@ -28,7 +28,6 @@
<string name="settings_backup_location">Backup location</string>
<string name="settings_backup_location_picker">Choose backup location</string>
<string name="settings_backup_location_title">Backup Location</string>
<string name="settings_backup_location_info">Choose where to store your backups. More options might get added in the future.</string>
<string name="settings_backup_location_invalid">The chosen location can not be used.</string>
<string name="settings_backup_external_storage">External Storage</string>
<string name="settings_info">All backups are encrypted on your phone. To restore from backup you will need your 12-word recovery code.</string>
@ -36,6 +35,18 @@
<string name="settings_auto_restore_summary">When reinstalling an app, restore backed up settings and data</string>
<string name="settings_backup_now">Backup now</string>
<!-- Storage -->
<string name="storage_fragment_backup_title">Choose where to store backups</string>
<string name="storage_fragment_restore_title">Where to find your backups?</string>
<string name="storage_fake_drive_title">USB Flash Drive</string>
<string name="storage_fake_drive_summary">Needs to be plugged in</string>
<string name="storage_available_bytes"><xliff:g id="size" example="1 GB">%1$s</xliff:g> free</string>
<string name="storage_check_fragment_backup_title">Initializing backup location…</string>
<string name="storage_check_fragment_restore_title">Looking for backups…</string>
<string name="storage_check_fragment_backup_error">An error occurred while accessing the backup location.</string>
<string name="storage_check_fragment_permission_error">Unable to get the permission to write to the backup location.</string>
<string name="storage_check_fragment_error_button">Back</string>
<!-- Recovery Code -->
<string name="recovery_code_title">Recovery Code</string>
<string name="recovery_code_12_word_intro">You need your 12-word recovery code to restore backed up data.</string>
@ -57,7 +68,7 @@
<string name="recovery_code_input_hint_12">Word 12</string>
<string name="recovery_code_error_empty_word">You forgot to enter this word.</string>
<string name="recovery_code_error_invalid_word">Wrong word. Did you mean %1$s or %2$s?</string>
<string name="recovery_code_error_checksum_word">We are so sorry! An unexpected error occurred.</string>
<string name="recovery_code_error_checksum_word">Your code is invalid. Please check all words and try again!</string>
<!-- Notification -->
<string name="notification_channel_title">Backup Notification</string>
@ -85,5 +96,9 @@
<string name="restore_finished_error">An error occurred while restoring the backup.</string>
<string name="restore_finished_warning_only_installed">Note that we could only restore data for apps that are already installed.\n\nWhen you install more apps, we will try to restore their data and settings from this backup. So please do not delete it as long as it might still be needed.</string>
<string name="restore_finished_button">Finish</string>
<string name="storage_internal_warning_title">Warning</string>
<string name="storage_internal_warning_message">You have chosen internal storage for your backup. This will not be available when your phone is lost or broken.</string>
<string name="storage_internal_warning_choose_other">Choose Other</string>
<string name="storage_internal_warning_use_anyway">Use anyway</string>
</resources>

View file

@ -1,11 +0,0 @@
<androidx.preference.PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto">
<androidx.preference.Preference
app:allowDividerAbove="true"
app:allowDividerBelow="false"
app:icon="@drawable/ic_info_outline"
app:selectable="false"
app:order="1337"
app:summary="@string/settings_backup_location_info" />
</androidx.preference.PreferenceScreen>