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:
parent
7455f4afb9
commit
6d8178f6b1
34 changed files with 1132 additions and 311 deletions
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
|
||||
}
|
|
@ -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()
|
|
@ -1,4 +1,4 @@
|
|||
package com.stevesoltys.backup.ui
|
||||
package com.stevesoltys.backup.ui.recoverycode
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
|
@ -1,4 +1,4 @@
|
|||
package com.stevesoltys.backup.ui
|
||||
package com.stevesoltys.backup.ui.recoverycode
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
|
@ -1,4 +1,4 @@
|
|||
package com.stevesoltys.backup.ui
|
||||
package com.stevesoltys.backup.ui.recoverycode
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
|
@ -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)
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
10
app/src/main/res/drawable/ic_usb.xml
Normal file
10
app/src/main/res/drawable/ic_usb.xml
Normal 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>
|
|
@ -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"
|
||||
|
|
76
app/src/main/res/layout/fragment_storage_check.xml
Normal file
76
app/src/main/res/layout/fragment_storage_check.xml
Normal 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>
|
68
app/src/main/res/layout/fragment_storage_root.xml
Normal file
68
app/src/main/res/layout/fragment_storage_root.xml
Normal 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>
|
53
app/src/main/res/layout/list_item_storage_root.xml
Normal file
53
app/src/main/res/layout/list_item_storage_root.xml
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
Loading…
Reference in a new issue