Merge pull request #38 from grote/restore

Implement new Restore UI
This commit is contained in:
Steve Soltys 2019-09-22 13:16:52 -04:00 committed by GitHub
commit 54ff9e8cf7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
81 changed files with 2672 additions and 584 deletions

View file

@ -1,13 +1,5 @@
LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE := default-permissions_com.stevesoltys.backup.xml
LOCAL_MODULE_CLASS := ETC
LOCAL_MODULE_TAGS := optional
LOCAL_MODULE_PATH := $(TARGET_OUT_ETC)/default-permissions
LOCAL_SRC_FILES := $(LOCAL_MODULE)
include $(BUILD_PREBUILT)
include $(CLEAR_VARS)
LOCAL_MODULE := permissions_com.stevesoltys.backup.xml
LOCAL_MODULE_CLASS := ETC
@ -38,4 +30,4 @@ LOCAL_MODULE_CLASS := APPS
LOCAL_PRIVILEGED_MODULE := true
LOCAL_DEX_PREOPT := false
LOCAL_REQUIRED_MODULES := permissions_com.stevesoltys.backup.xml whitelist_com.stevesoltys.backup.xml
include $(BUILD_PREBUILT)
include $(BUILD_PREBUILT)

View file

@ -106,10 +106,10 @@ dependencies {
implementation 'commons-io:commons-io:2.6'
implementation 'io.github.novacrypto:BIP39:2019.01.27'
implementation 'androidx.core:core-ktx:1.0.2'
implementation 'androidx.preference:preference-ktx:1.0.0'
implementation 'androidx.core:core-ktx:1.1.0'
implementation 'androidx.preference:preference-ktx:1.1.0'
implementation 'com.google.android.material:material:1.0.0'
implementation 'androidx.lifecycle:lifecycle-extensions:2.0.0'
implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
lintChecks 'com.github.thirdegg:lint-rules:0.0.4-alpha'

View file

@ -3,7 +3,8 @@ package com.stevesoltys.backup
import androidx.documentfile.provider.DocumentFile
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.runner.AndroidJUnit4
import com.stevesoltys.backup.settings.getBackupFolderUri
import com.stevesoltys.backup.settings.getBackupToken
import com.stevesoltys.backup.settings.getStorage
import com.stevesoltys.backup.transport.backup.plugins.DocumentsStorage
import com.stevesoltys.backup.transport.backup.plugins.createOrGetFile
import org.junit.After
@ -20,9 +21,9 @@ private const val filename = "test-file"
class DocumentsStorageTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val folderUri = getBackupFolderUri(context)
private val deviceName = "device name"
private val storage = DocumentsStorage(context, folderUri, deviceName)
private val token = getBackupToken(context)
private val folderUri = getStorage(context)
private val storage = DocumentsStorage(context, folderUri, token)
private lateinit var file: DocumentFile

View file

@ -14,9 +14,10 @@
android:name="android.permission.BACKUP"
tools:ignore="ProtectedPermissions" />
<!-- This is needed to retrieve the serial number of the device,
so we can store the backups for each device in a unique location -->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<!-- This is needed to retrieve the available storage roots -->
<uses-permission
android:name="android.permission.MANAGE_DOCUMENTS"
tools:ignore="ProtectedPermissions" />
<application
android:name=".Backup"
@ -32,8 +33,29 @@
android:exported="true" />
<activity
android:name=".settings.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"
android:exported="true"
android:label="@string/restore_title"
android:theme="@style/AppTheme.NoActionBar">
<intent-filter>
<action android:name="com.stevesoltys.backup.restore.RESTORE_BACKUP" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name="com.stevesoltys.backup.activity.MainActivity"

View file

@ -1,23 +1,14 @@
package com.stevesoltys.backup
import android.Manifest.permission.READ_PHONE_STATE
import android.app.Application
import android.app.backup.IBackupManager
import android.content.Context.BACKUP_SERVICE
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.net.Uri
import android.os.Build
import android.os.ServiceManager.getService
import android.util.Log
import com.stevesoltys.backup.crypto.KeyManager
import com.stevesoltys.backup.crypto.KeyManagerImpl
import com.stevesoltys.backup.settings.getDeviceName
import com.stevesoltys.backup.settings.setDeviceName
import io.github.novacrypto.hashing.Sha256.sha256Twice
private const val URI_AUTHORITY_EXTERNAL_STORAGE = "com.android.externalstorage.documents"
private val TAG = Backup::class.java.simpleName
import com.stevesoltys.backup.ui.storage.AUTHORITY_STORAGE
/**
* @author Steve Soltys
@ -38,27 +29,6 @@ class Backup : Application() {
BackupNotificationManager(this)
}
override fun onCreate() {
super.onCreate()
storeDeviceName()
}
private fun storeDeviceName() {
if (getDeviceName(this) != null) return // we already have a stored device name
val permission = READ_PHONE_STATE
if (checkSelfPermission(permission) != PERMISSION_GRANTED) {
throw AssertionError("You need to grant the $permission permission.")
}
// TODO consider just using a hash for the entire device name and store metadata in an encrypted file
val id = sha256Twice(Build.getSerial().toByteArray(Utf8))
.copyOfRange(0, 8)
.encodeBase64()
val name = "${Build.MANUFACTURER} ${Build.MODEL} ($id)"
Log.i(TAG, "Initialized device name to: $name")
setDeviceName(this, name)
}
}
fun Uri.isOnExternalStorage() = authority == URI_AUTHORITY_EXTERNAL_STORAGE
fun isDebugBuild() = Build.TYPE == "userdebug"

View file

@ -3,6 +3,7 @@ package com.stevesoltys.backup
import android.app.backup.BackupProgress
import android.app.backup.IBackupObserver
import android.content.Context
import android.content.pm.PackageManager
import android.util.Log
import android.util.Log.INFO
import android.util.Log.isLoggable
@ -62,10 +63,12 @@ class NotificationBackupObserver(context: Context, private val userInitiated: Bo
nm.onBackupFinished()
}
private fun getAppName(packageId: String): CharSequence {
if (packageId == "@pm@") return packageId
val appInfo = pm.getApplicationInfo(packageId, 0)
return pm.getApplicationLabel(appInfo)
}
private fun getAppName(packageId: String): CharSequence = getAppName(pm, packageId)
}
fun getAppName(pm: PackageManager, packageId: String): CharSequence {
if (packageId == "@pm@") return packageId
val appInfo = pm.getApplicationInfo(packageId, 0)
return pm.getApplicationLabel(appInfo)
}

View file

@ -18,9 +18,8 @@ import static android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION;
import static android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION;
import static com.stevesoltys.backup.activity.MainActivity.OPEN_DOCUMENT_TREE_BACKUP_REQUEST_CODE;
import static com.stevesoltys.backup.activity.MainActivity.OPEN_DOCUMENT_TREE_REQUEST_CODE;
import static com.stevesoltys.backup.settings.SettingsManagerKt.getBackupFolderUri;
import static com.stevesoltys.backup.settings.SettingsManagerKt.getBackupPassword;
import static com.stevesoltys.backup.settings.SettingsManagerKt.setBackupFolderUri;
import static com.stevesoltys.backup.settings.SettingsManagerKt.getStorage;
/**
* @author Steve Soltys
@ -31,7 +30,7 @@ public class MainActivityController {
public static final String DOCUMENT_MIME_TYPE = "application/octet-stream";
void onBackupButtonClicked(Activity parent) {
Uri folderUri = getBackupFolderUri(parent);
Uri folderUri = null;
if (folderUri == null) {
showChooseFolderActivity(parent, true);
} else {
@ -42,7 +41,7 @@ public class MainActivityController {
}
boolean isChangeBackupLocationButtonVisible(Activity parent) {
return getBackupFolderUri(parent) != null;
return getStorage(parent) != null;
}
private void showChooseFolderActivity(Activity parent, boolean continueToBackup) {
@ -75,7 +74,7 @@ public class MainActivityController {
}
boolean onAutomaticBackupsButtonClicked(Activity parent) {
if (getBackupFolderUri(parent) == null || getBackupPassword(parent) == null) {
if (getStorage(parent) == null || getBackupPassword(parent) == null) {
Toast.makeText(parent, "Please make at least one manual backup first.", Toast.LENGTH_SHORT).show();
return false;
}
@ -103,9 +102,6 @@ public class MainActivityController {
(FLAG_GRANT_READ_URI_PERMISSION | FLAG_GRANT_WRITE_URI_PERMISSION);
parent.getContentResolver().takePersistableUriPermission(folderUri, takeFlags);
// store backup folder location in settings
setBackupFolderUri(parent, folderUri);
if (!continueToBackup) return;
showCreateBackupActivity(parent);

View file

@ -0,0 +1,28 @@
package com.stevesoltys.backup.metadata
import android.os.Build
import android.os.Build.VERSION.SDK_INT
import com.stevesoltys.backup.header.VERSION
import java.io.InputStream
data class BackupMetadata(
internal val version: Byte = VERSION,
internal val token: Long,
internal val androidVersion: Int = SDK_INT,
internal val deviceName: String = "${Build.MANUFACTURER} ${Build.MODEL}"
)
internal const val JSON_VERSION = "version"
internal const val JSON_TOKEN = "token"
internal const val JSON_ANDROID_VERSION = "androidVersion"
internal const val JSON_DEVICE_NAME = "deviceName"
class DecryptionFailedException(cause: Throwable) : Exception(cause)
class EncryptedBackupMetadata private constructor(val token: Long, val inputStream: InputStream?, val error: Boolean) {
constructor(token: Long, inputStream: InputStream) : this(token, inputStream, false)
/**
* Indicates that there was an error retrieving the encrypted backup metadata.
*/
constructor(token: Long) : this(token, null, true)
}

View file

@ -0,0 +1,65 @@
package com.stevesoltys.backup.metadata
import androidx.annotation.VisibleForTesting
import com.stevesoltys.backup.Utf8
import com.stevesoltys.backup.crypto.Crypto
import com.stevesoltys.backup.header.UnsupportedVersionException
import com.stevesoltys.backup.header.VERSION
import org.json.JSONException
import org.json.JSONObject
import java.io.IOException
import java.io.InputStream
import javax.crypto.AEADBadTagException
interface MetadataReader {
@Throws(SecurityException::class, DecryptionFailedException::class, UnsupportedVersionException::class, IOException::class)
fun readMetadata(inputStream: InputStream, expectedToken: Long): BackupMetadata
}
class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
@Throws(SecurityException::class, DecryptionFailedException::class, UnsupportedVersionException::class, IOException::class)
override fun readMetadata(inputStream: InputStream, expectedToken: Long): BackupMetadata {
val version = inputStream.read().toByte()
if (version < 0) throw IOException()
if (version > VERSION) throw UnsupportedVersionException(version)
val metadataBytes = try {
crypto.decryptSegment(inputStream)
} catch (e: AEADBadTagException) {
throw DecryptionFailedException(e)
}
return decode(metadataBytes, version, expectedToken)
}
@VisibleForTesting
@Throws(SecurityException::class)
internal fun decode(bytes: ByteArray, expectedVersion: Byte, expectedToken: Long): BackupMetadata {
// NOTE: We don't do extensive validation of the parsed input here,
// because it was encrypted with authentication, so we should be able to trust it.
//
// However, it is important to ensure that the expected unauthenticated version and token
// matches the authenticated version and token in the JSON.
try {
val json = JSONObject(bytes.toString(Utf8))
val version = json.getInt(JSON_VERSION).toByte()
if (version != expectedVersion) {
throw SecurityException("Invalid version '${version.toInt()}' in metadata, expected '${expectedVersion.toInt()}'.")
}
val token = json.getLong(JSON_TOKEN)
if (token != expectedToken) {
throw SecurityException("Invalid token '$token' in metadata, expected '$expectedToken'.")
}
return BackupMetadata(
version = version,
token = token,
androidVersion = json.getInt(JSON_ANDROID_VERSION),
deviceName = json.getString(JSON_DEVICE_NAME)
)
} catch (e: JSONException) {
throw SecurityException(e)
}
}
}

View file

@ -0,0 +1,36 @@
package com.stevesoltys.backup.metadata
import androidx.annotation.VisibleForTesting
import com.stevesoltys.backup.Utf8
import com.stevesoltys.backup.crypto.Crypto
import org.json.JSONObject
import java.io.IOException
import java.io.OutputStream
interface MetadataWriter {
@Throws(IOException::class)
fun write(outputStream: OutputStream, token: Long)
}
class MetadataWriterImpl(private val crypto: Crypto): MetadataWriter {
@Throws(IOException::class)
override fun write(outputStream: OutputStream, token: Long) {
val metadata = BackupMetadata(token = token)
outputStream.write(ByteArray(1).apply { this[0] = metadata.version })
crypto.encryptSegment(outputStream, encode(metadata))
}
@VisibleForTesting
internal fun encode(metadata: BackupMetadata): ByteArray {
val json = JSONObject()
json.put(JSON_VERSION, metadata.version.toInt())
json.put(JSON_TOKEN, metadata.token)
json.put(JSON_ANDROID_VERSION, metadata.androidVersion)
json.put(JSON_DEVICE_NAME, metadata.deviceName)
return json.toString().toByteArray(Utf8)
}
}

View file

@ -0,0 +1,47 @@
package com.stevesoltys.backup.restore
import android.os.Bundle
import androidx.annotation.CallSuper
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import com.stevesoltys.backup.R
import com.stevesoltys.backup.ui.RequireProvisioningActivity
import com.stevesoltys.backup.ui.RequireProvisioningViewModel
class RestoreActivity : RequireProvisioningActivity() {
private lateinit var viewModel: RestoreViewModel
override fun getViewModel(): RequireProvisioningViewModel = viewModel
override fun onCreate(savedInstanceState: Bundle?) {
viewModel = ViewModelProviders.of(this).get(RestoreViewModel::class.java)
super.onCreate(savedInstanceState)
if (isSetupWizard) hideSystemUI()
setContentView(R.layout.activity_fragment_container)
viewModel.chosenRestoreSet.observe(this, Observer { set ->
if (set != null) showFragment(RestoreProgressFragment())
})
if (savedInstanceState == null) {
showFragment(RestoreSetFragment())
}
}
@CallSuper
override fun onStart() {
super.onStart()
if (isFinishing) return
// check that backup is provisioned
if (!viewModel.validLocationIsSet()) {
showStorageActivity()
} else if (!viewModel.recoveryCodeIsSet()) {
showRecoveryCodeActivity()
}
}
}

View file

@ -0,0 +1,69 @@
package com.stevesoltys.backup.restore
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.view.ViewGroup
import android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import com.stevesoltys.backup.R
import com.stevesoltys.backup.getAppName
import com.stevesoltys.backup.isDebugBuild
import com.stevesoltys.backup.settings.getStorage
import kotlinx.android.synthetic.main.fragment_restore_progress.*
class RestoreProgressFragment : Fragment() {
private lateinit var viewModel: RestoreViewModel
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_restore_progress, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
// decryption will fail when the device is locked, so keep the screen on to prevent locking
requireActivity().window.addFlags(FLAG_KEEP_SCREEN_ON)
viewModel = ViewModelProviders.of(requireActivity()).get(RestoreViewModel::class.java)
viewModel.chosenRestoreSet.observe(this, Observer { set ->
backupNameView.text = set.device
})
viewModel.restoreProgress.observe(this, Observer { currentPackage ->
val appName = getAppName(requireActivity().packageManager, currentPackage)
val displayName = if (isDebugBuild()) "$appName (${currentPackage})" else appName
currentPackageView.text = getString(R.string.restore_current_package, displayName)
})
viewModel.restoreFinished.observe(this, Observer { finished ->
progressBar.visibility = INVISIBLE
button.visibility = VISIBLE
if (finished == 0) {
// success
currentPackageView.text = getString(R.string.restore_finished_success)
warningView.text = if (getStorage(requireContext())?.ejectable == true) {
getString(R.string.restore_finished_warning_only_installed, getString(R.string.restore_finished_warning_ejectable))
} else {
getString(R.string.restore_finished_warning_only_installed, null)
}
warningView.visibility = VISIBLE
} else {
// error
currentPackageView.text = getString(R.string.restore_finished_error)
currentPackageView.setTextColor(warningView.textColors)
}
activity?.window?.clearFlags(FLAG_KEEP_SCREEN_ON)
})
button.setOnClickListener { requireActivity().finishAfterTransition() }
}
}

View file

@ -0,0 +1,42 @@
package com.stevesoltys.backup.restore
import android.app.backup.RestoreSet
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView.Adapter
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.stevesoltys.backup.R
import com.stevesoltys.backup.restore.RestoreSetAdapter.RestoreSetViewHolder
internal class RestoreSetAdapter(
private val listener: RestoreSetClickListener,
private val items: Array<out RestoreSet>) : Adapter<RestoreSetViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RestoreSetViewHolder {
val v = LayoutInflater.from(parent.context)
.inflate(R.layout.list_item_restore_set, parent, false) as View
return RestoreSetViewHolder(v)
}
override fun getItemCount() = items.size
override fun onBindViewHolder(holder: RestoreSetViewHolder, position: Int) {
holder.bind(items[position])
}
inner class RestoreSetViewHolder(private val v: View) : ViewHolder(v) {
private val titleView = v.findViewById<TextView>(R.id.titleView)
private val subtitleView = v.findViewById<TextView>(R.id.subtitleView)
internal fun bind(item: RestoreSet) {
v.setOnClickListener { listener.onRestoreSetClicked(item) }
titleView.text = item.name
subtitleView.text = "Android Backup" // TODO change to backup date when available
}
}
}

View file

@ -0,0 +1,61 @@
package com.stevesoltys.backup.restore
import android.app.backup.RestoreSet
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import com.stevesoltys.backup.R
import kotlinx.android.synthetic.main.fragment_restore_set.*
class RestoreSetFragment : Fragment() {
private lateinit var viewModel: RestoreViewModel
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_restore_set, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
viewModel = ViewModelProviders.of(requireActivity()).get(RestoreViewModel::class.java)
viewModel.restoreSets.observe(this, Observer { result -> onRestoreSetsLoaded(result) })
backView.setOnClickListener { requireActivity().finishAfterTransition() }
}
override fun onStart() {
super.onStart()
if (viewModel.recoveryCodeIsSet() && viewModel.validLocationIsSet()) {
viewModel.loadRestoreSets()
}
}
private fun onRestoreSetsLoaded(result: RestoreSetResult) {
if (result.hasError()) {
errorView.visibility = VISIBLE
listView.visibility = INVISIBLE
progressBar.visibility = INVISIBLE
errorView.text = result.errorMsg
} else {
errorView.visibility = INVISIBLE
listView.visibility = VISIBLE
progressBar.visibility = INVISIBLE
listView.adapter = RestoreSetAdapter(viewModel, result.sets)
}
}
}
internal interface RestoreSetClickListener {
fun onRestoreSetClicked(set: RestoreSet)
}

View file

@ -0,0 +1,148 @@
package com.stevesoltys.backup.restore
import android.app.Application
import android.app.backup.IRestoreObserver
import android.app.backup.IRestoreSession
import android.app.backup.RestoreSet
import android.util.Log
import androidx.annotation.WorkerThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.stevesoltys.backup.Backup
import com.stevesoltys.backup.R
import com.stevesoltys.backup.session.backup.BackupMonitor
import com.stevesoltys.backup.transport.TRANSPORT_ID
import com.stevesoltys.backup.ui.RequireProvisioningViewModel
private val TAG = RestoreViewModel::class.java.simpleName
class RestoreViewModel(app: Application) : RequireProvisioningViewModel(app), RestoreSetClickListener {
override val isRestoreOperation = true
private val backupManager = Backup.backupManager
private var session: IRestoreSession? = null
private var observer: RestoreObserver? = null
private val monitor = BackupMonitor()
private val mRestoreSets = MutableLiveData<RestoreSetResult>()
internal val restoreSets: LiveData<RestoreSetResult> get() = mRestoreSets
private val mChosenRestoreSet = MutableLiveData<RestoreSet>()
internal val chosenRestoreSet: LiveData<RestoreSet> get() = mChosenRestoreSet
private val mRestoreProgress = MutableLiveData<String>()
internal val restoreProgress: LiveData<String> get() = mRestoreProgress
private val mRestoreFinished = MutableLiveData<Int>()
// Zero on success; a nonzero error code if the restore operation as a whole failed.
internal val restoreFinished: LiveData<Int> get() = mRestoreFinished
internal fun loadRestoreSets() {
val session = this.session ?: backupManager.beginRestoreSession(null, TRANSPORT_ID)
this.session = session
if (session == null) {
Log.e(TAG, "beginRestoreSession() returned null session")
mRestoreSets.value = RestoreSetResult(app.getString(R.string.restore_set_error))
return
}
val observer = this.observer ?: RestoreObserver()
this.observer = observer
val setResult = session.getAvailableRestoreSets(observer, monitor)
if (setResult != 0) {
Log.e(TAG, "getAvailableRestoreSets() returned non-zero value")
mRestoreSets.value = RestoreSetResult(app.getString(R.string.restore_set_error))
return
}
}
override fun onRestoreSetClicked(set: RestoreSet) {
val session = this.session
check(session != null)
session.restoreAll(set.token, observer, monitor)
mChosenRestoreSet.value = set
}
override fun onCleared() {
super.onCleared()
endSession()
}
private fun endSession() {
session?.endRestoreSession()
session = null
observer = null
}
@WorkerThread
private inner class RestoreObserver : IRestoreObserver.Stub() {
/**
* Supply a list of the restore datasets available from the current transport.
* This method is invoked as a callback following the application's use of the
* [IRestoreSession.getAvailableRestoreSets] method.
*
* @param restoreSets An array of [RestoreSet] objects
* describing all of the available datasets that are candidates for restoring to
* the current device. If no applicable datasets exist, restoreSets will be null.
*/
override fun restoreSetsAvailable(restoreSets: Array<out RestoreSet>?) {
if (restoreSets == null || restoreSets.isEmpty()) {
mRestoreSets.postValue(RestoreSetResult(app.getString(R.string.restore_set_empty_result)))
} else {
mRestoreSets.postValue(RestoreSetResult(restoreSets))
}
}
/**
* The restore operation has begun.
*
* @param numPackages The total number of packages being processed in this restore operation.
*/
override fun restoreStarting(numPackages: Int) {
// noop
}
/**
* An indication of which package is being restored currently,
* out of the total number provided in the [restoreStarting] callback.
* This method is not guaranteed to be called.
*
* @param nowBeingRestored The index, between 1 and the numPackages parameter
* to the [restoreStarting] callback, of the package now being restored.
* @param currentPackage The name of the package now being restored.
*/
override fun onUpdate(nowBeingRestored: Int, currentPackage: String) {
// nowBeingRestored reporting is buggy, so don't use it
mRestoreProgress.postValue(currentPackage)
}
/**
* The restore operation has completed.
*
* @param result Zero on success; a nonzero error code if the restore operation
* as a whole failed.
*/
override fun restoreFinished(result: Int) {
mRestoreFinished.postValue(result)
endSession()
}
}
}
internal class RestoreSetResult(
internal val sets: Array<out RestoreSet>,
internal val errorMsg: String?) {
internal constructor(sets: Array<out RestoreSet>) : this(sets, null)
internal constructor(errorMsg: String) : this(emptyArray(), errorMsg)
internal fun hasError(): Boolean = errorMsg != null
}

View file

@ -1,61 +0,0 @@
package com.stevesoltys.backup.settings
import android.app.Activity.RESULT_OK
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.Intent.*
import android.os.Bundle
import android.provider.DocumentsContract.EXTRA_PROMPT
import android.widget.Toast
import android.widget.Toast.LENGTH_LONG
import androidx.lifecycle.ViewModelProviders
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import com.stevesoltys.backup.R
private val TAG = BackupLocationFragment::class.java.name
class BackupLocationFragment : PreferenceFragmentCompat() {
private lateinit var viewModel: SettingsViewModel
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.backup_location, rootKey)
requireActivity().setTitle(R.string.settings_backup_location_title)
viewModel = ViewModelProviders.of(requireActivity()).get(SettingsViewModel::class.java)
val externalStorage = Preference(requireContext()).apply {
setIcon(R.drawable.ic_storage)
setTitle(R.string.settings_backup_external_storage)
setOnPreferenceClickListener {
showChooseFolderActivity()
true
}
}
preferenceScreen.addPreference(externalStorage)
}
private fun showChooseFolderActivity() {
val openTreeIntent = Intent(ACTION_OPEN_DOCUMENT_TREE)
openTreeIntent.putExtra(EXTRA_PROMPT, getString(R.string.settings_backup_location_picker))
openTreeIntent.addFlags(FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION)
try {
val documentChooser = createChooser(openTreeIntent, null)
startActivityForResult(documentChooser, REQUEST_CODE_OPEN_DOCUMENT_TREE)
} catch (ex: ActivityNotFoundException) {
Toast.makeText(requireContext(), "Please install a file manager.", LENGTH_LONG).show()
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, result: Intent?) {
if (resultCode == RESULT_OK && requestCode == REQUEST_CODE_OPEN_DOCUMENT_TREE) {
viewModel.handleChooseFolderResult(result)
} else {
super.onActivityResult(requestCode, resultCode, result)
}
}
}

View file

@ -1,55 +0,0 @@
package com.stevesoltys.backup.settings
import android.os.Bundle
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModelProviders
import com.stevesoltys.backup.LiveEventHandler
import com.stevesoltys.backup.R
class RecoveryCodeActivity : AppCompatActivity() {
private lateinit var viewModel: RecoveryCodeViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_recovery_code)
viewModel = ViewModelProviders.of(this).get(RecoveryCodeViewModel::class.java)
viewModel.confirmButtonClicked.observeEvent(this, LiveEventHandler { clicked ->
if (clicked) {
val tag = "Confirm"
supportFragmentManager.beginTransaction()
.replace(R.id.fragment, RecoveryCodeInputFragment(), tag)
.addToBackStack(tag)
.commit()
}
})
viewModel.recoveryCodeSaved.observeEvent(this, LiveEventHandler { saved ->
if (saved) {
setResult(RESULT_OK)
finishAfterTransition()
}
})
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
if (savedInstanceState == null) {
supportFragmentManager.beginTransaction()
.add(R.id.fragment, RecoveryCodeOutputFragment(), "Code")
.commit()
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when {
item.itemId == android.R.id.home -> {
onBackPressed()
true
}
else -> super.onOptionsItemSelected(item)
}
}
}

View file

@ -1,53 +1,31 @@
package com.stevesoltys.backup.settings
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.annotation.CallSuper
import androidx.lifecycle.ViewModelProviders
import com.stevesoltys.backup.Backup
import com.stevesoltys.backup.LiveEventHandler
import com.stevesoltys.backup.R
import com.stevesoltys.backup.ui.RequireProvisioningActivity
import com.stevesoltys.backup.ui.RequireProvisioningViewModel
private val TAG = SettingsActivity::class.java.name
const val REQUEST_CODE_OPEN_DOCUMENT_TREE = 1
const val REQUEST_CODE_RECOVERY_CODE = 2
class SettingsActivity : AppCompatActivity() {
class SettingsActivity : RequireProvisioningActivity() {
private lateinit var viewModel: SettingsViewModel
override fun getViewModel(): RequireProvisioningViewModel = viewModel
override fun onCreate(savedInstanceState: Bundle?) {
viewModel = ViewModelProviders.of(this).get(SettingsViewModel::class.java)
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)
viewModel = ViewModelProviders.of(this).get(SettingsViewModel::class.java)
viewModel.onLocationSet.observeEvent(this, LiveEventHandler { initialSetUp ->
if (initialSetUp) showFragment(SettingsFragment())
else supportFragmentManager.popBackStack()
})
viewModel.chooseBackupLocation.observeEvent(this, LiveEventHandler { show ->
if (show) showFragment(BackupLocationFragment(), true)
})
setContentView(R.layout.activity_fragment_container)
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
if (savedInstanceState == null) showFragment(SettingsFragment())
}
override fun onActivityResult(requestCode: Int, resultCode: Int, result: Intent?) {
if (resultCode != RESULT_OK) {
Log.w(TAG, "Error in activity result: $requestCode")
finishAfterTransition()
} else {
super.onActivityResult(requestCode, resultCode, result)
}
}
@CallSuper
override fun onStart() {
super.onStart()
if (isFinishing) return
@ -56,30 +34,10 @@ class SettingsActivity : AppCompatActivity() {
if (!viewModel.recoveryCodeIsSet()) {
showRecoveryCodeActivity()
} else if (!viewModel.validLocationIsSet()) {
showFragment(BackupLocationFragment())
showStorageActivity()
// remove potential error notifications
(application as Backup).notificationManager.onBackupErrorSeen()
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when {
item.itemId == android.R.id.home -> {
onBackPressed()
true
}
else -> super.onOptionsItemSelected(item)
}
private fun showRecoveryCodeActivity() {
val intent = Intent(this, RecoveryCodeActivity::class.java)
startActivityForResult(intent, REQUEST_CODE_RECOVERY_CODE)
}
private fun showFragment(f: Fragment, addToBackStack: Boolean = false) {
val fragmentTransaction = supportFragmentManager.beginTransaction()
.replace(R.id.fragment, f)
if (addToBackStack) fragmentTransaction.addToBackStack(null)
fragmentTransaction.commit()
}
}

View file

@ -1,6 +1,7 @@
package com.stevesoltys.backup.settings
import android.content.Context.BACKUP_SERVICE
import android.content.Intent
import android.os.Bundle
import android.os.RemoteException
import android.provider.Settings
@ -9,14 +10,14 @@ import android.util.Log
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.widget.Toast
import android.widget.Toast.LENGTH_SHORT
import androidx.lifecycle.ViewModelProviders
import androidx.preference.Preference
import androidx.preference.Preference.OnPreferenceChangeListener
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.TwoStatePreference
import com.stevesoltys.backup.Backup
import com.stevesoltys.backup.R
import com.stevesoltys.backup.restore.RestoreActivity
private val TAG = SettingsFragment::class.java.name
@ -28,6 +29,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
private lateinit var backup: TwoStatePreference
private lateinit var autoRestore: TwoStatePreference
private lateinit var backupLocation: Preference
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings, rootKey)
@ -35,7 +37,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
viewModel = ViewModelProviders.of(requireActivity()).get(SettingsViewModel::class.java)
backup = findPreference("backup") as TwoStatePreference
backup = findPreference<TwoStatePreference>("backup")!!
backup.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
val enabled = newValue as Boolean
try {
@ -48,13 +50,13 @@ class SettingsFragment : PreferenceFragmentCompat() {
}
}
val backupLocation = findPreference("backup_location")
backupLocation = findPreference<Preference>("backup_location")!!
backupLocation.setOnPreferenceClickListener {
viewModel.chooseBackupLocation()
true
}
autoRestore = findPreference("auto_restore") as TwoStatePreference
autoRestore = findPreference<TwoStatePreference>("auto_restore")!!
autoRestore.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
val enabled = newValue as Boolean
try {
@ -84,6 +86,10 @@ class SettingsFragment : PreferenceFragmentCompat() {
val resolver = requireContext().contentResolver
autoRestore.isChecked = Settings.Secure.getInt(resolver, BACKUP_AUTO_RESTORE, 1) == 1
// TODO add time of last backup here
val storageName = getStorage(requireContext())?.name
backupLocation.summary = storageName ?: getString(R.string.settings_backup_location_none )
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
@ -100,7 +106,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
true
}
item.itemId == R.id.action_restore -> {
Toast.makeText(requireContext(), "Not yet implemented", LENGTH_SHORT).show()
startActivity(Intent(requireContext(), RestoreActivity::class.java))
true
}
else -> super.onOptionsItemSelected(item)

View file

@ -3,33 +3,54 @@ package com.stevesoltys.backup.settings
import android.content.Context
import android.net.Uri
import android.preference.PreferenceManager.getDefaultSharedPreferences
import java.util.*
private const val PREF_KEY_BACKUP_URI = "backupUri"
private const val PREF_KEY_DEVICE_NAME = "deviceName"
private const val PREF_KEY_STORAGE_URI = "storageUri"
private const val PREF_KEY_STORAGE_NAME = "storageName"
private const val PREF_KEY_STORAGE_EJECTABLE = "storageEjectable"
private const val PREF_KEY_BACKUP_TOKEN = "backupToken"
private const val PREF_KEY_BACKUP_PASSWORD = "backupLegacyPassword"
fun setBackupFolderUri(context: Context, uri: Uri) {
data class Storage(
val uri: Uri,
val name: String,
val ejectable: Boolean
)
fun setStorage(context: Context, storage: Storage) {
getDefaultSharedPreferences(context)
.edit()
.putString(PREF_KEY_BACKUP_URI, uri.toString())
.putString(PREF_KEY_STORAGE_URI, storage.uri.toString())
.putString(PREF_KEY_STORAGE_NAME, storage.name)
.putBoolean(PREF_KEY_STORAGE_EJECTABLE, storage.ejectable)
.apply()
}
fun getBackupFolderUri(context: Context): Uri? {
val uriStr = getDefaultSharedPreferences(context).getString(PREF_KEY_BACKUP_URI, null)
?: return null
return Uri.parse(uriStr)
fun getStorage(context: Context): Storage? {
val prefs = getDefaultSharedPreferences(context)
val uriStr = prefs.getString(PREF_KEY_STORAGE_URI, null) ?: return null
val uri = Uri.parse(uriStr)
val name = prefs.getString(PREF_KEY_STORAGE_NAME, null) ?: throw IllegalStateException()
val ejectable = prefs.getBoolean(PREF_KEY_STORAGE_EJECTABLE, false)
return Storage(uri, name, ejectable)
}
fun setDeviceName(context: Context, name: String) {
/**
* Generates and returns a new backup token while saving it as well.
* Subsequent calls to [getBackupToken] will return this new token once saved.
*/
fun getAndSaveNewBackupToken(context: Context): Long = Date().time.apply {
getDefaultSharedPreferences(context)
.edit()
.putString(PREF_KEY_DEVICE_NAME, name)
.putLong(PREF_KEY_BACKUP_TOKEN, this)
.apply()
}
fun getDeviceName(context: Context): String? {
return getDefaultSharedPreferences(context).getString(PREF_KEY_DEVICE_NAME, null)
/**
* Returns the current backup token or 0 if none exists.
*/
fun getBackupToken(context: Context): Long {
return getDefaultSharedPreferences(context).getLong(PREF_KEY_BACKUP_TOKEN, 0L)
}
@Deprecated("Replaced by KeyManager#getBackupKey()")

View file

@ -1,67 +1,21 @@
package com.stevesoltys.backup.settings
import android.app.Application
import android.content.Intent
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.AndroidViewModel
import com.stevesoltys.backup.Backup
import com.stevesoltys.backup.LiveEvent
import com.stevesoltys.backup.MutableLiveEvent
import com.stevesoltys.backup.isOnExternalStorage
import com.stevesoltys.backup.transport.ConfigurableBackupTransportService
import com.stevesoltys.backup.R
import com.stevesoltys.backup.transport.requestBackup
import com.stevesoltys.backup.ui.RequireProvisioningViewModel
private val TAG = SettingsViewModel::class.java.simpleName
class SettingsViewModel(application: Application) : AndroidViewModel(application) {
class SettingsViewModel(app: Application) : RequireProvisioningViewModel(app) {
private val app = application
override val isRestoreOperation = false
private val locationWasSet = MutableLiveEvent<Boolean>()
/**
* Will be set to true if this is the initial location.
* It will be false if an existing location was changed.
*/
internal val onLocationSet: LiveEvent<Boolean> = locationWasSet
private val mChooseBackupLocation = MutableLiveEvent<Boolean>()
internal val chooseBackupLocation: LiveEvent<Boolean> = mChooseBackupLocation
internal fun chooseBackupLocation() = mChooseBackupLocation.setEvent(true)
fun recoveryCodeIsSet() = Backup.keyManager.hasBackupKey()
fun validLocationIsSet(): Boolean {
val uri = getBackupFolderUri(app) ?: return false
if (uri.isOnExternalStorage()) return true // might be a temporary failure
val file = DocumentFile.fromTreeUri(app, uri) ?: return false
return file.isDirectory
fun backupNow() {
val nm = (app as Backup).notificationManager
nm.onBackupUpdate(app.getString(R.string.notification_backup_starting), 0, 1, true)
Thread { requestBackup(app) }.start()
}
fun handleChooseFolderResult(result: Intent?) {
val folderUri = result?.data ?: return
// persist permission to access backup folder across reboots
val takeFlags = result.flags and (FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION)
app.contentResolver.takePersistableUriPermission(folderUri, takeFlags)
// check if this is initial set-up or a later change
val initialSetUp = !validLocationIsSet()
// store backup folder location in settings
setBackupFolderUri(app, folderUri)
// notify the UI that the location has been set
locationWasSet.setEvent(initialSetUp)
// stop backup service to be sure the old location will get updated
app.stopService(Intent(app, ConfigurableBackupTransportService::class.java))
Log.d(TAG, "New storage location chosen: $folderUri")
}
fun backupNow() = Thread { requestBackup(app) }.start()
}

View file

@ -12,7 +12,7 @@ import android.os.ParcelFileDescriptor
import android.util.Log
import com.stevesoltys.backup.settings.SettingsActivity
const val DEFAULT_RESTORE_SET_TOKEN: Long = 1
val TRANSPORT_ID: String = ConfigurableBackupTransport::class.java.name
private const val TRANSPORT_DIRECTORY_NAME = "com.stevesoltys.backup.transport.ConfigurableBackupTransport"
private val TAG = ConfigurableBackupTransport::class.java.simpleName
@ -32,8 +32,7 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
}
override fun name(): String {
// TODO: Make this class non-static in ConfigurableBackupTransportService and use Context and a ComponentName.
return this.javaClass.name
return TRANSPORT_ID
}
override fun getTransportFlags(): Int {

View file

@ -6,8 +6,10 @@ import com.stevesoltys.backup.crypto.CipherFactoryImpl
import com.stevesoltys.backup.crypto.CryptoImpl
import com.stevesoltys.backup.header.HeaderReaderImpl
import com.stevesoltys.backup.header.HeaderWriterImpl
import com.stevesoltys.backup.settings.getBackupFolderUri
import com.stevesoltys.backup.settings.getDeviceName
import com.stevesoltys.backup.metadata.MetadataReaderImpl
import com.stevesoltys.backup.metadata.MetadataWriterImpl
import com.stevesoltys.backup.settings.getBackupToken
import com.stevesoltys.backup.settings.getStorage
import com.stevesoltys.backup.transport.backup.BackupCoordinator
import com.stevesoltys.backup.transport.backup.FullBackup
import com.stevesoltys.backup.transport.backup.InputFactory
@ -24,12 +26,14 @@ class PluginManager(context: Context) {
// We can think about using an injection framework such as Dagger to simplify this.
private val storage = DocumentsStorage(context, getBackupFolderUri(context), getDeviceName(context)!!)
private val storage = DocumentsStorage(context, getStorage(context), getBackupToken(context))
private val headerWriter = HeaderWriterImpl()
private val headerReader = HeaderReaderImpl()
private val cipherFactory = CipherFactoryImpl(Backup.keyManager)
private val crypto = CryptoImpl(cipherFactory, headerWriter, headerReader)
private val metadataWriter = MetadataWriterImpl(crypto)
private val metadataReader = MetadataReaderImpl(crypto)
private val backupPlugin = DocumentsProviderBackupPlugin(storage, context.packageManager)
@ -38,7 +42,7 @@ class PluginManager(context: Context) {
private val fullBackup = FullBackup(backupPlugin.fullBackupPlugin, inputFactory, headerWriter, crypto)
private val notificationManager = (context.applicationContext as Backup).notificationManager
internal val backupCoordinator = BackupCoordinator(backupPlugin, kvBackup, fullBackup, notificationManager)
internal val backupCoordinator = BackupCoordinator(context, backupPlugin, kvBackup, fullBackup, metadataWriter, notificationManager)
private val restorePlugin = DocumentsProviderRestorePlugin(storage)
@ -46,6 +50,6 @@ class PluginManager(context: Context) {
private val kvRestore = KVRestore(restorePlugin.kvRestorePlugin, outputFactory, headerReader, crypto)
private val fullRestore = FullRestore(restorePlugin.fullRestorePlugin, outputFactory, headerReader, crypto)
internal val restoreCoordinator = RestoreCoordinator(restorePlugin, kvRestore, fullRestore)
internal val restoreCoordinator = RestoreCoordinator(context, restorePlugin, kvRestore, fullRestore, metadataReader)
}

View file

@ -2,10 +2,13 @@ package com.stevesoltys.backup.transport.backup
import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_OK
import android.content.Context
import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import android.util.Log
import com.stevesoltys.backup.BackupNotificationManager
import com.stevesoltys.backup.metadata.MetadataWriter
import com.stevesoltys.backup.settings.getBackupToken
import java.io.IOException
private val TAG = BackupCoordinator::class.java.simpleName
@ -15,9 +18,11 @@ private val TAG = BackupCoordinator::class.java.simpleName
* @author Torsten Grote
*/
class BackupCoordinator(
private val context: Context,
private val plugin: BackupPlugin,
private val kv: KVBackup,
private val full: FullBackup,
private val metadataWriter: MetadataWriter,
private val nm: BackupNotificationManager) {
private var calledInitialize = false
@ -49,6 +54,7 @@ class BackupCoordinator(
Log.i(TAG, "Initialize Device!")
return try {
plugin.initializeDevice()
writeBackupMetadata(getBackupToken(context))
// [finishBackup] will only be called when we return [TRANSPORT_OK] here
// so we remember that we initialized successfully
calledInitialize = true
@ -129,11 +135,11 @@ class BackupCoordinator(
fun finishBackup(): Int = when {
kv.hasState() -> {
if (full.hasState()) throw IllegalStateException()
check(!full.hasState())
kv.finishBackup()
}
full.hasState() -> {
if (kv.hasState()) throw IllegalStateException()
check(!kv.hasState())
full.finishBackup()
}
calledInitialize || calledClearBackupData -> {
@ -144,4 +150,10 @@ class BackupCoordinator(
else -> throw IllegalStateException()
}
@Throws(IOException::class)
private fun writeBackupMetadata(token: Long) {
val outputStream = plugin.getMetadataOutputStream()
metadataWriter.write(outputStream, token)
}
}

View file

@ -1,6 +1,7 @@
package com.stevesoltys.backup.transport.backup
import java.io.IOException
import java.io.OutputStream
interface BackupPlugin {
@ -14,6 +15,12 @@ interface BackupPlugin {
@Throws(IOException::class)
fun initializeDevice()
/**
* Returns an [OutputStream] for writing backup metadata.
*/
@Throws(IOException::class)
fun getMetadataOutputStream(): OutputStream
/**
* Returns the package name of the app that provides the backend storage
* which is used for the current backup location.

View file

@ -141,7 +141,7 @@ class KVBackup(
val base64Key = key.encodeBase64()
val dataSize = changeSet.dataSize
// read and encrypt value
// read value
val value = if (dataSize >= 0) {
Log.v(TAG, " Delta operation key $key size $dataSize key64 $base64Key")
val bytes = ByteArray(dataSize)

View file

@ -5,6 +5,7 @@ import com.stevesoltys.backup.transport.backup.BackupPlugin
import com.stevesoltys.backup.transport.backup.FullBackupPlugin
import com.stevesoltys.backup.transport.backup.KVBackupPlugin
import java.io.IOException
import java.io.OutputStream
class DocumentsProviderBackupPlugin(
private val storage: DocumentsStorage,
@ -24,14 +25,22 @@ class DocumentsProviderBackupPlugin(
storage.rootBackupDir ?: throw IOException()
// create backup folders
val kvDir = storage.defaultKvBackupDir
val fullDir = storage.defaultFullBackupDir
val kvDir = storage.currentKvBackupDir
val fullDir = storage.currentFullBackupDir
// wipe existing data
storage.getSetDir()?.findFile(FILE_BACKUP_METADATA)?.delete()
kvDir?.deleteContents()
fullDir?.deleteContents()
}
@Throws(IOException::class)
override fun getMetadataOutputStream(): OutputStream {
val setDir = storage.getSetDir() ?: throw IOException()
val metadataFile = setDir.createOrGetFile(FILE_BACKUP_METADATA)
return storage.getOutputStream(metadataFile)
}
override val providerPackageName: String? by lazy {
val authority = storage.rootBackupDir?.uri?.authority ?: return@lazy null
val providerInfo = packageManager.resolveContentProvider(authority, 0) ?: return@lazy null

View file

@ -16,7 +16,7 @@ class DocumentsProviderFullBackup(
@Throws(IOException::class)
override fun getOutputStream(targetPackage: PackageInfo): OutputStream {
val file = storage.defaultFullBackupDir?.createOrGetFile(targetPackage.packageName)
val file = storage.currentFullBackupDir?.createOrGetFile(targetPackage.packageName)
?: throw IOException()
return storage.getOutputStream(file)
}
@ -25,7 +25,7 @@ class DocumentsProviderFullBackup(
override fun removeDataOfPackage(packageInfo: PackageInfo) {
val packageName = packageInfo.packageName
Log.i(TAG, "Deleting $packageName...")
val file = storage.defaultFullBackupDir?.findFile(packageName) ?: return
val file = storage.currentFullBackupDir?.findFile(packageName) ?: return
if (!file.delete()) throw IOException("Failed to delete $packageName")
}

View file

@ -15,7 +15,7 @@ class DocumentsProviderKVBackup(private val storage: DocumentsStorage) : KVBacku
@Throws(IOException::class)
override fun hasDataForPackage(packageInfo: PackageInfo): Boolean {
val packageFile = storage.defaultKvBackupDir?.findFile(packageInfo.packageName)
val packageFile = storage.currentKvBackupDir?.findFile(packageInfo.packageName)
?: return false
return packageFile.listFiles().isNotEmpty()
}
@ -30,7 +30,7 @@ class DocumentsProviderKVBackup(private val storage: DocumentsStorage) : KVBacku
override fun removeDataOfPackage(packageInfo: PackageInfo) {
// we cannot use the cached this.packageFile here,
// because this can be called before [ensureRecordStorageForPackage]
val packageFile = storage.defaultKvBackupDir?.findFile(packageInfo.packageName) ?: return
val packageFile = storage.currentKvBackupDir?.findFile(packageInfo.packageName) ?: return
packageFile.delete()
}

View file

@ -2,33 +2,33 @@ package com.stevesoltys.backup.transport.backup.plugins
import android.content.Context
import android.content.pm.PackageInfo
import android.net.Uri
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.backup.transport.DEFAULT_RESTORE_SET_TOKEN
import com.stevesoltys.backup.settings.Storage
import com.stevesoltys.backup.settings.getAndSaveNewBackupToken
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
const val DIRECTORY_ROOT = ".AndroidBackup"
const val DIRECTORY_FULL_BACKUP = "full"
const val DIRECTORY_KEY_VALUE_BACKUP = "kv"
private const val ROOT_DIR_NAME = ".AndroidBackup"
private const val NO_MEDIA = ".nomedia"
const val FILE_BACKUP_METADATA = ".backup.metadata"
const val FILE_NO_MEDIA = ".nomedia"
private const val MIME_TYPE = "application/octet-stream"
private val TAG = DocumentsStorage::class.java.simpleName
class DocumentsStorage(context: Context, parentFolder: Uri?, deviceName: String) {
private val contentResolver = context.contentResolver
class DocumentsStorage(private val context: Context, storage: Storage?, token: Long) {
internal val rootBackupDir: DocumentFile? by lazy {
val folderUri = parentFolder ?: return@lazy null
val folderUri = storage?.uri ?: return@lazy null
// [fromTreeUri] should only return null when SDK_INT < 21
val parent = DocumentFile.fromTreeUri(context, folderUri) ?: throw AssertionError()
try {
val rootDir = parent.createOrGetDirectory(ROOT_DIR_NAME)
val rootDir = parent.createOrGetDirectory(DIRECTORY_ROOT)
// create .nomedia file to prevent Android's MediaScanner from trying to index the backup
rootDir.createOrGetFile(NO_MEDIA)
rootDir.createOrGetFile(FILE_NO_MEDIA)
rootDir
} catch (e: IOException) {
Log.e(TAG, "Error creating root backup dir.", e)
@ -36,73 +36,71 @@ class DocumentsStorage(context: Context, parentFolder: Uri?, deviceName: String)
}
}
private val deviceDir: DocumentFile? by lazy {
private val currentToken: Long by lazy {
if (token != 0L) token
else getAndSaveNewBackupToken(context).apply {
Log.d(TAG, "Using a fresh backup token: $this")
}
}
private val currentSetDir: DocumentFile? by lazy {
val currentSetName = currentToken.toString()
try {
rootBackupDir?.createOrGetDirectory(deviceName)
rootBackupDir?.createOrGetDirectory(currentSetName)
} catch (e: IOException) {
Log.e(TAG, "Error creating current restore set dir.", e)
null
}
}
private val defaultSetDir: DocumentFile? by lazy {
val currentSetName = DEFAULT_RESTORE_SET_TOKEN.toString()
val currentFullBackupDir: DocumentFile? by lazy {
try {
deviceDir?.createOrGetDirectory(currentSetName)
} catch (e: IOException) {
Log.e(TAG, "Error creating current restore set dir.", e)
null
}
}
val defaultFullBackupDir: DocumentFile? by lazy {
try {
defaultSetDir?.createOrGetDirectory(DIRECTORY_FULL_BACKUP)
currentSetDir?.createOrGetDirectory(DIRECTORY_FULL_BACKUP)
} catch (e: IOException) {
Log.e(TAG, "Error creating full backup dir.", e)
null
}
}
val defaultKvBackupDir: DocumentFile? by lazy {
val currentKvBackupDir: DocumentFile? by lazy {
try {
defaultSetDir?.createOrGetDirectory(DIRECTORY_KEY_VALUE_BACKUP)
currentSetDir?.createOrGetDirectory(DIRECTORY_KEY_VALUE_BACKUP)
} catch (e: IOException) {
Log.e(TAG, "Error creating K/V backup dir.", e)
null
}
}
private fun getSetDir(token: Long = DEFAULT_RESTORE_SET_TOKEN): DocumentFile? {
if (token == DEFAULT_RESTORE_SET_TOKEN) return defaultSetDir
return deviceDir?.findFile(token.toString())
fun getSetDir(token: Long = currentToken): DocumentFile? {
if (token == currentToken) return currentSetDir
return rootBackupDir?.findFile(token.toString())
}
fun getKVBackupDir(token: Long = DEFAULT_RESTORE_SET_TOKEN): DocumentFile? {
if (token == DEFAULT_RESTORE_SET_TOKEN) return defaultKvBackupDir ?: throw IOException()
fun getKVBackupDir(token: Long = currentToken): DocumentFile? {
if (token == currentToken) return currentKvBackupDir ?: throw IOException()
return getSetDir(token)?.findFile(DIRECTORY_KEY_VALUE_BACKUP)
}
@Throws(IOException::class)
fun getOrCreateKVBackupDir(token: Long = DEFAULT_RESTORE_SET_TOKEN): DocumentFile {
if (token == DEFAULT_RESTORE_SET_TOKEN) return defaultKvBackupDir ?: throw IOException()
fun getOrCreateKVBackupDir(token: Long = currentToken): DocumentFile {
if (token == currentToken) return currentKvBackupDir ?: throw IOException()
val setDir = getSetDir(token) ?: throw IOException()
return setDir.createOrGetDirectory(DIRECTORY_KEY_VALUE_BACKUP)
}
fun getFullBackupDir(token: Long = DEFAULT_RESTORE_SET_TOKEN): DocumentFile? {
if (token == DEFAULT_RESTORE_SET_TOKEN) return defaultFullBackupDir ?: throw IOException()
fun getFullBackupDir(token: Long = currentToken): DocumentFile? {
if (token == currentToken) return currentFullBackupDir ?: throw IOException()
return getSetDir(token)?.findFile(DIRECTORY_FULL_BACKUP)
}
@Throws(IOException::class)
fun getInputStream(file: DocumentFile): InputStream {
return contentResolver.openInputStream(file.uri) ?: throw IOException()
return context.contentResolver.openInputStream(file.uri) ?: throw IOException()
}
@Throws(IOException::class)
fun getOutputStream(file: DocumentFile): OutputStream {
return contentResolver.openOutputStream(file.uri) ?: throw IOException()
return context.contentResolver.openOutputStream(file.uri) ?: throw IOException()
}
}

View file

@ -13,6 +13,7 @@ import com.stevesoltys.backup.header.UnsupportedVersionException
import libcore.io.IoUtils.closeQuietly
import java.io.IOException
import java.util.*
import javax.crypto.AEADBadTagException
private class KVRestoreState(
internal val token: Long,
@ -86,6 +87,9 @@ internal class KVRestore(
} catch (e: UnsupportedVersionException) {
Log.e(TAG, "Unsupported version in backup: ${e.version}", e)
TRANSPORT_ERROR
} catch (e: AEADBadTagException) {
Log.e(TAG, "Decryption failed", e)
TRANSPORT_ERROR
} finally {
this.state = null
closeQuietly(data)

View file

@ -5,9 +5,15 @@ import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.RestoreDescription
import android.app.backup.RestoreDescription.*
import android.app.backup.RestoreSet
import android.content.Context
import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import android.util.Log
import com.stevesoltys.backup.header.UnsupportedVersionException
import com.stevesoltys.backup.metadata.DecryptionFailedException
import com.stevesoltys.backup.metadata.MetadataReader
import com.stevesoltys.backup.settings.getBackupToken
import libcore.io.IoUtils.closeQuietly
import java.io.IOException
private class RestoreCoordinatorState(
@ -17,19 +23,59 @@ private class RestoreCoordinatorState(
private val TAG = RestoreCoordinator::class.java.simpleName
internal class RestoreCoordinator(
private val context: Context,
private val plugin: RestorePlugin,
private val kv: KVRestore,
private val full: FullRestore) {
private val full: FullRestore,
private val metadataReader: MetadataReader) {
private var state: RestoreCoordinatorState? = null
/**
* Get the set of all backups currently available over this transport.
*
* @return Descriptions of the set of restore images available for this device,
* or null if an error occurred (the attempt should be rescheduled).
**/
fun getAvailableRestoreSets(): Array<RestoreSet>? {
return plugin.getAvailableRestoreSets()
.apply { Log.i(TAG, "Got available restore sets: $this") }
val availableBackups = plugin.getAvailableBackups() ?: return null
val restoreSets = ArrayList<RestoreSet>()
for (encryptedMetadata in availableBackups) {
if (encryptedMetadata.error) continue
check(encryptedMetadata.inputStream != null) // if there's no error, there must be a stream
try {
val metadata = metadataReader.readMetadata(encryptedMetadata.inputStream, encryptedMetadata.token)
val set = RestoreSet(metadata.deviceName, metadata.deviceName, metadata.token)
restoreSets.add(set)
} catch (e: IOException) {
Log.e(TAG, "Error while getting restore sets", e)
return null
} catch (e: SecurityException) {
Log.e(TAG, "Error while getting restore sets", e)
return null
} catch (e: DecryptionFailedException) {
Log.e(TAG, "Error while decrypting restore set", e)
continue
} catch (e: UnsupportedVersionException) {
Log.w(TAG, "Backup with unsupported version read", e)
continue
} finally {
closeQuietly(encryptedMetadata.inputStream)
}
}
Log.i(TAG, "Got available restore sets: $restoreSets")
return restoreSets.toTypedArray()
}
/**
* Get the identifying token of the backup set currently being stored from this device.
* This is used in the case of applications wishing to restore their last-known-good data.
*
* @return A token that can be used for restore,
* or 0 if there is no backup set available corresponding to the current device state.
*/
fun getCurrentRestoreSet(): Long {
return plugin.getCurrentRestoreSet()
return getBackupToken(context)
.apply { Log.i(TAG, "Got current restore set token: $this") }
}
@ -46,7 +92,7 @@ internal class RestoreCoordinator(
* or [TRANSPORT_ERROR] (an error occurred, the restore should be aborted and rescheduled).
*/
fun startRestore(token: Long, packages: Array<out PackageInfo>): Int {
if (state != null) throw IllegalStateException()
check(state == null)
Log.i(TAG, "Start restore with ${packages.map { info -> info.packageName }}")
state = RestoreCoordinatorState(token, packages.iterator())
return TRANSPORT_OK

View file

@ -1,6 +1,6 @@
package com.stevesoltys.backup.transport.restore
import android.app.backup.RestoreSet
import com.stevesoltys.backup.metadata.EncryptedBackupMetadata
interface RestorePlugin {
@ -11,18 +11,9 @@ interface RestorePlugin {
/**
* Get the set of all backups currently available for restore.
*
* @return Descriptions of the set of restore images available for this device,
* @return metadata for the set of restore images available,
* or null if an error occurred (the attempt should be rescheduled).
**/
fun getAvailableRestoreSets(): Array<RestoreSet>?
/**
* Get the identifying token of the backup set currently being stored from this device.
* This is used in the case of applications wishing to restore their last-known-good data.
*
* @return A token that can be used for restore,
* or 0 if there is no backup set available corresponding to the current device state.
*/
fun getCurrentRestoreSet(): Long
fun getAvailableBackups(): Sequence<EncryptedBackupMetadata>?
}

View file

@ -1,29 +1,72 @@
package com.stevesoltys.backup.transport.restore.plugins
import android.app.backup.RestoreSet
import com.stevesoltys.backup.transport.DEFAULT_RESTORE_SET_TOKEN
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.backup.metadata.EncryptedBackupMetadata
import com.stevesoltys.backup.transport.backup.plugins.DocumentsStorage
import com.stevesoltys.backup.transport.backup.plugins.FILE_BACKUP_METADATA
import com.stevesoltys.backup.transport.backup.plugins.FILE_NO_MEDIA
import com.stevesoltys.backup.transport.restore.FullRestorePlugin
import com.stevesoltys.backup.transport.restore.KVRestorePlugin
import com.stevesoltys.backup.transport.restore.RestorePlugin
import java.io.IOException
class DocumentsProviderRestorePlugin(
private val documentsStorage: DocumentsStorage) : RestorePlugin {
private val TAG = DocumentsProviderRestorePlugin::class.java.simpleName
class DocumentsProviderRestorePlugin(private val storage: DocumentsStorage) : RestorePlugin {
override val kvRestorePlugin: KVRestorePlugin by lazy {
DocumentsProviderKVRestorePlugin(documentsStorage)
DocumentsProviderKVRestorePlugin(storage)
}
override val fullRestorePlugin: FullRestorePlugin by lazy {
DocumentsProviderFullRestorePlugin(documentsStorage)
DocumentsProviderFullRestorePlugin(storage)
}
override fun getAvailableRestoreSets(): Array<RestoreSet>? {
return arrayOf(RestoreSet("default", "device", DEFAULT_RESTORE_SET_TOKEN))
override fun getAvailableBackups(): Sequence<EncryptedBackupMetadata>? {
val rootDir = storage.rootBackupDir ?: return null
val backupSets = getBackups(rootDir)
val iterator = backupSets.iterator()
return generateSequence {
if (!iterator.hasNext()) return@generateSequence null // end sequence
val backupSet = iterator.next()
try {
val stream = storage.getInputStream(backupSet.metadataFile)
EncryptedBackupMetadata(backupSet.token, stream)
} catch (e: IOException) {
Log.e(TAG, "Error getting InputStream for backup metadata.", e)
EncryptedBackupMetadata(backupSet.token)
}
}
}
override fun getCurrentRestoreSet(): Long {
return DEFAULT_RESTORE_SET_TOKEN
companion object {
fun getBackups(rootDir: DocumentFile): List<BackupSet> {
val backupSets = ArrayList<BackupSet>()
for (set in rootDir.listFiles()) {
if (!set.isDirectory || set.name == null) {
if (set.name != FILE_NO_MEDIA) {
Log.w(TAG, "Found invalid backup set folder: ${set.name}")
}
continue
}
val token = try {
set.name!!.toLong()
} catch (e: NumberFormatException) {
Log.w(TAG, "Found invalid backup set folder: ${set.name}", e)
continue
}
val metadata = set.findFile(FILE_BACKUP_METADATA)
if (metadata == null) {
Log.w(TAG, "Missing metadata file in backup set folder: ${set.name}")
} else {
backupSets.add(BackupSet(token, metadata))
}
}
return backupSets
}
}
}
class BackupSet(val token: Long, val metadataFile: DocumentFile)

View file

@ -0,0 +1,34 @@
package com.stevesoltys.backup.ui
import android.view.MenuItem
import android.view.View
import androidx.annotation.CallSuper
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import com.stevesoltys.backup.R
abstract class BackupActivity : AppCompatActivity() {
@CallSuper
override fun onOptionsItemSelected(item: MenuItem): Boolean = when {
item.itemId == android.R.id.home -> {
onBackPressed()
true
}
else -> super.onOptionsItemSelected(item)
}
protected fun showFragment(f: Fragment, addToBackStack: Boolean = false) {
val fragmentTransaction = supportFragmentManager.beginTransaction()
.replace(R.id.fragment, f)
if (addToBackStack) fragmentTransaction.addToBackStack(null)
fragmentTransaction.commit()
}
protected fun hideSystemUI() {
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_FULLSCREEN)
}
}

View file

@ -1,9 +1,9 @@
package com.stevesoltys.backup
package com.stevesoltys.backup.ui
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import com.stevesoltys.backup.LiveEvent.ConsumableEvent
import com.stevesoltys.backup.ui.LiveEvent.ConsumableEvent
open class LiveEvent<T> : LiveData<ConsumableEvent<T>>() {

View file

@ -1,4 +1,4 @@
package com.stevesoltys.backup;
package com.stevesoltys.backup.ui;
public interface LiveEventHandler<T> {
void onEvent(T t);

View file

@ -1,4 +1,4 @@
package com.stevesoltys.backup
package com.stevesoltys.backup.ui
class MutableLiveEvent<T> : LiveEvent<T>() {

View file

@ -0,0 +1,72 @@
package com.stevesoltys.backup.ui
import android.content.Intent
import android.os.Bundle
import android.util.Log
import androidx.annotation.CallSuper
import com.stevesoltys.backup.ui.recoverycode.RecoveryCodeActivity
import com.stevesoltys.backup.ui.storage.StorageActivity
const val REQUEST_CODE_OPEN_DOCUMENT_TREE = 1
const val REQUEST_CODE_BACKUP_LOCATION = 2
const val REQUEST_CODE_RECOVERY_CODE = 3
const val INTENT_EXTRA_IS_RESTORE = "isRestore"
const val INTENT_EXTRA_IS_SETUP_WIZARD = "isSetupWizard"
private const val ACTION_SETUP_WIZARD = "com.stevesoltys.backup.restore.RESTORE_BACKUP"
private val TAG = RequireProvisioningActivity::class.java.name
/**
* An Activity that requires the recovery code and the backup location to be set up
* before starting.
*/
abstract class RequireProvisioningActivity : BackupActivity() {
protected val isSetupWizard: Boolean
get() = intent?.action == ACTION_SETUP_WIZARD
protected abstract fun getViewModel(): RequireProvisioningViewModel
@CallSuper
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
getViewModel().chooseBackupLocation.observeEvent(this, LiveEventHandler { show ->
if (show) showStorageActivity()
})
}
@CallSuper
override fun onActivityResult(requestCode: Int, resultCode: Int, result: Intent?) {
if (requestCode == REQUEST_CODE_BACKUP_LOCATION && resultCode != RESULT_OK) {
Log.w(TAG, "Error in activity result: $requestCode")
if (!getViewModel().validLocationIsSet()) {
finishAfterTransition()
}
} else if (requestCode == REQUEST_CODE_RECOVERY_CODE && resultCode != RESULT_OK) {
Log.w(TAG, "Error in activity result: $requestCode")
if (!getViewModel().recoveryCodeIsSet()) {
finishAfterTransition()
}
} else {
super.onActivityResult(requestCode, resultCode, result)
}
}
protected fun showStorageActivity() {
val intent = Intent(this, StorageActivity::class.java)
intent.putExtra(INTENT_EXTRA_IS_RESTORE, getViewModel().isRestoreOperation)
intent.putExtra(INTENT_EXTRA_IS_SETUP_WIZARD, isSetupWizard)
startActivityForResult(intent, REQUEST_CODE_BACKUP_LOCATION)
}
protected fun showRecoveryCodeActivity() {
val intent = Intent(this, RecoveryCodeActivity::class.java)
intent.putExtra(INTENT_EXTRA_IS_RESTORE, getViewModel().isRestoreOperation)
intent.putExtra(INTENT_EXTRA_IS_SETUP_WIZARD, isSetupWizard)
startActivityForResult(intent, REQUEST_CODE_RECOVERY_CODE)
}
}

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

@ -0,0 +1,73 @@
package com.stevesoltys.backup.ui.recoverycode
import android.os.Bundle
import android.view.MenuItem
import androidx.lifecycle.ViewModelProviders
import com.stevesoltys.backup.R
import com.stevesoltys.backup.ui.BackupActivity
import com.stevesoltys.backup.ui.INTENT_EXTRA_IS_RESTORE
import com.stevesoltys.backup.ui.INTENT_EXTRA_IS_SETUP_WIZARD
import com.stevesoltys.backup.ui.LiveEventHandler
class RecoveryCodeActivity : BackupActivity() {
private lateinit var viewModel: RecoveryCodeViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (isSetupWizard()) hideSystemUI()
setContentView(R.layout.activity_recovery_code)
viewModel = ViewModelProviders.of(this).get(RecoveryCodeViewModel::class.java)
viewModel.isRestore = isRestore()
viewModel.confirmButtonClicked.observeEvent(this, LiveEventHandler { clicked ->
if (clicked) showInput(true)
})
viewModel.recoveryCodeSaved.observeEvent(this, LiveEventHandler { saved ->
if (saved) {
setResult(RESULT_OK)
finishAfterTransition()
}
})
if (savedInstanceState == null) {
if (viewModel.isRestore) showInput(false)
else showOutput()
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when {
item.itemId == android.R.id.home -> {
onBackPressed()
true
}
else -> super.onOptionsItemSelected(item)
}
}
private fun showOutput() {
supportFragmentManager.beginTransaction()
.add(R.id.fragment, RecoveryCodeOutputFragment(), "Code")
.commit()
}
private fun showInput(addToBackStack: Boolean) {
val tag = "Confirm"
val fragmentTransaction = supportFragmentManager.beginTransaction()
.replace(R.id.fragment, RecoveryCodeInputFragment(), tag)
if (addToBackStack) fragmentTransaction.addToBackStack(tag)
fragmentTransaction.commit()
}
private fun isRestore(): Boolean {
return intent?.getBooleanExtra(INTENT_EXTRA_IS_RESTORE, false) ?: false
}
private fun isSetupWizard(): Boolean {
return intent?.getBooleanExtra(INTENT_EXTRA_IS_SETUP_WIZARD, false) ?: false
}
}

View file

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

View file

@ -1,18 +1,21 @@
package com.stevesoltys.backup.settings
package com.stevesoltys.backup.ui.recoverycode
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.View.OnFocusChangeListener
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.AutoCompleteTextView
import android.widget.Toast
import android.widget.Toast.LENGTH_LONG
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProviders
import com.stevesoltys.backup.R
import com.stevesoltys.backup.isDebugBuild
import io.github.novacrypto.bip39.Validation.InvalidChecksumException
import io.github.novacrypto.bip39.Validation.WordNotFoundException
import io.github.novacrypto.bip39.wordlists.English
import kotlinx.android.synthetic.main.fragment_recovery_code_input.*
import kotlinx.android.synthetic.main.recovery_code_input.*
@ -29,15 +32,29 @@ class RecoveryCodeInputFragment : Fragment() {
super.onActivityCreated(savedInstanceState)
viewModel = ViewModelProviders.of(requireActivity()).get(RecoveryCodeViewModel::class.java)
if (viewModel.isRestore) introText.setText(R.string.recovery_code_input_intro)
val adapter = getAdapter()
for (i in 0 until WORD_NUM) {
val wordLayout = getWordLayout(i)
wordLayout.editText!!.onFocusChangeListener = OnFocusChangeListener { _, focus ->
val editText = wordLayout.editText as AutoCompleteTextView
editText.onFocusChangeListener = OnFocusChangeListener { _, focus ->
if (!focus) wordLayout.isErrorEnabled = false
}
editText.setAdapter(adapter)
}
doneButton.setOnClickListener { done() }
if (Build.TYPE == "userdebug") debugPreFill()
if (isDebugBuild() && !viewModel.isRestore) debugPreFill()
}
private fun getAdapter(): ArrayAdapter<String> {
val adapter = ArrayAdapter<String>(requireContext(), android.R.layout.simple_list_item_1)
for (i in 0 until WORD_LIST_SIZE) {
adapter.add(English.INSTANCE.getWord(i))
}
return adapter
}
private fun getInput(): List<CharSequence> = ArrayList<String>(WORD_NUM).apply {
@ -57,7 +74,7 @@ class RecoveryCodeInputFragment : Fragment() {
}
private fun allFilledOut(input: List<CharSequence>): Boolean {
for (i in 0 until input.size) {
for (i in input.indices) {
if (input[i].isNotEmpty()) continue
showError(i, getString(R.string.recovery_code_error_empty_word))
return false
@ -96,7 +113,7 @@ class RecoveryCodeInputFragment : Fragment() {
private fun debugPreFill() {
val words = viewModel.wordList
for (i in 0 until words.size) {
for (i in words.indices) {
getWordLayout(i).editText!!.setText(words[i])
}
}

View file

@ -1,6 +1,6 @@
package com.stevesoltys.backup.settings
package com.stevesoltys.backup.ui.recoverycode
import android.content.res.Configuration
import android.content.res.Configuration.ORIENTATION_LANDSCAPE
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@ -33,12 +33,10 @@ class RecoveryCodeOutputFragment : Fragment() {
private fun setGridParameters(list: RecyclerView) {
val layoutManager = list.layoutManager as GridLayoutManager
if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
layoutManager.orientation = RecyclerView.VERTICAL
if (resources.configuration.orientation == ORIENTATION_LANDSCAPE) {
layoutManager.spanCount = 4
} else {
layoutManager.orientation = RecyclerView.HORIZONTAL
layoutManager.spanCount = 6
layoutManager.spanCount = 2
}
}

View file

@ -1,10 +1,10 @@
package com.stevesoltys.backup.settings
package com.stevesoltys.backup.ui.recoverycode
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import com.stevesoltys.backup.Backup
import com.stevesoltys.backup.LiveEvent
import com.stevesoltys.backup.MutableLiveEvent
import com.stevesoltys.backup.ui.LiveEvent
import com.stevesoltys.backup.ui.MutableLiveEvent
import io.github.novacrypto.bip39.*
import io.github.novacrypto.bip39.Validation.InvalidChecksumException
import io.github.novacrypto.bip39.Validation.InvalidWordCountException
@ -15,6 +15,7 @@ import java.security.SecureRandom
import java.util.*
internal const val WORD_NUM = 12
internal const val WORD_LIST_SIZE = 2048
class RecoveryCodeViewModel(application: Application) : AndroidViewModel(application) {
@ -35,6 +36,8 @@ class RecoveryCodeViewModel(application: Application) : AndroidViewModel(applica
private val mRecoveryCodeSaved = MutableLiveEvent<Boolean>()
internal val recoveryCodeSaved: LiveEvent<Boolean> = mRecoveryCodeSaved
internal var isRestore: Boolean = false
@Throws(WordNotFoundException::class, InvalidChecksumException::class)
fun validateAndContinue(input: List<CharSequence>) {
try {

View file

@ -0,0 +1,56 @@
package com.stevesoltys.backup.ui.storage
import android.app.Application
import android.app.backup.BackupProgress
import android.app.backup.IBackupObserver
import android.net.Uri
import android.util.Log
import androidx.annotation.WorkerThread
import com.stevesoltys.backup.Backup
import com.stevesoltys.backup.R
import com.stevesoltys.backup.settings.getAndSaveNewBackupToken
import com.stevesoltys.backup.transport.TRANSPORT_ID
private val TAG = BackupStorageViewModel::class.java.simpleName
internal class BackupStorageViewModel(private val app: Application) : StorageViewModel(app) {
override val isRestoreOperation = false
override fun onLocationSet(uri: Uri) {
saveStorage(uri)
// use a new backup token
getAndSaveNewBackupToken(app)
// initialize the new location
val observer = InitializationObserver()
Backup.backupManager.initializeTransports(arrayOf(TRANSPORT_ID), observer)
}
@WorkerThread
private inner class InitializationObserver : IBackupObserver.Stub() {
override fun onUpdate(currentBackupPackage: String, backupProgress: BackupProgress) {
// noop
}
override fun onResult(target: String, status: Int) {
// noop
}
override fun backupFinished(status: Int) {
if (Log.isLoggable(TAG, Log.INFO)) {
Log.i(TAG, "Initialization finished. Status: $status")
}
if (status == 0) {
// notify the UI that the location has been set
mLocationChecked.postEvent(LocationResult())
} else {
// notify the UI that the location was invalid
val errorMsg = app.getString(R.string.storage_check_fragment_backup_error)
mLocationChecked.postEvent(LocationResult(errorMsg))
}
}
}
}

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,47 @@
package com.stevesoltys.backup.ui.storage
import android.app.Application
import android.net.Uri
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.backup.R
import com.stevesoltys.backup.transport.backup.plugins.DIRECTORY_ROOT
import com.stevesoltys.backup.transport.restore.plugins.DocumentsProviderRestorePlugin
private val TAG = RestoreStorageViewModel::class.java.simpleName
internal class RestoreStorageViewModel(private val app: Application) : StorageViewModel(app) {
override val isRestoreOperation = true
override fun onLocationSet(uri: Uri) {
if (hasBackup(uri)) {
saveStorage(uri)
mLocationChecked.setEvent(LocationResult())
} else {
Log.w(TAG, "Location was rejected: $uri")
// notify the UI that the location was invalid
val errorMsg = app.getString(R.string.restore_invalid_location_message, DIRECTORY_ROOT)
mLocationChecked.setEvent(LocationResult(errorMsg))
}
}
/**
* Searches if there's really a backup available in the given location.
* Returns true if at least one was found and false otherwise.
*
* This method is not plugin-agnostic and breaks encapsulation.
* It is specific to the (currently only) DocumentsProvider plugin.
*
* TODO maybe move this to the RestoreCoordinator once we can inject it
*/
private fun hasBackup(folderUri: Uri): Boolean {
val parent = DocumentFile.fromTreeUri(app, folderUri) ?: throw AssertionError()
val rootDir = parent.findFile(DIRECTORY_ROOT) ?: return false
val backupSets = DocumentsProviderRestorePlugin.getBackups(rootDir)
return backupSets.isNotEmpty()
}
}

View file

@ -0,0 +1,99 @@
package com.stevesoltys.backup.ui.storage
import android.content.Intent
import android.os.Bundle
import android.util.Log
import androidx.annotation.CallSuper
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.ViewModelProviders
import com.stevesoltys.backup.R
import com.stevesoltys.backup.ui.BackupActivity
import com.stevesoltys.backup.ui.INTENT_EXTRA_IS_RESTORE
import com.stevesoltys.backup.ui.INTENT_EXTRA_IS_SETUP_WIZARD
import com.stevesoltys.backup.ui.LiveEventHandler
private val TAG = StorageActivity::class.java.name
class StorageActivity : BackupActivity() {
private lateinit var viewModel: StorageViewModel
@CallSuper
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (isSetupWizard()) hideSystemUI()
setContentView(R.layout.activity_fragment_container)
viewModel = if (isRestore()) {
ViewModelProviders.of(this).get(RestoreStorageViewModel::class.java)
} else {
ViewModelProviders.of(this).get(BackupStorageViewModel::class.java)
}
viewModel.locationSet.observeEvent(this, LiveEventHandler {
showFragment(StorageCheckFragment.newInstance(getCheckFragmentTitle()), true)
})
viewModel.locationChecked.observeEvent(this, LiveEventHandler { result ->
val errorMsg = result.errorMsg
if (errorMsg == null) {
setResult(RESULT_OK)
finishAfterTransition()
} else {
onInvalidLocation(errorMsg)
}
})
if (savedInstanceState == null) {
showFragment(StorageRootsFragment.newInstance(isRestore()))
}
}
@CallSuper
override fun onActivityResult(requestCode: Int, resultCode: Int, result: Intent?) {
if (resultCode != RESULT_OK) {
Log.w(TAG, "Error in activity result: $requestCode")
onInvalidLocation(getString(R.string.storage_check_fragment_permission_error))
} else {
super.onActivityResult(requestCode, resultCode, result)
}
}
override fun onBackPressed() {
if (supportFragmentManager.backStackEntryCount > 0) {
Log.d(TAG, "Blocking back button.")
} else {
super.onBackPressed()
}
}
private fun onInvalidLocation(errorMsg: String) {
if (viewModel.isRestoreOperation) {
supportFragmentManager.popBackStack()
AlertDialog.Builder(this)
.setTitle(getString(R.string.restore_invalid_location_title))
.setMessage(errorMsg)
.setPositiveButton(android.R.string.ok) { dialog, _ -> dialog.dismiss() }
.show()
} else {
showFragment(StorageCheckFragment.newInstance(getCheckFragmentTitle(), errorMsg))
}
}
private fun isRestore(): Boolean {
return intent?.getBooleanExtra(INTENT_EXTRA_IS_RESTORE, false) ?: false
}
private fun isSetupWizard(): Boolean {
return intent?.getBooleanExtra(INTENT_EXTRA_IS_SETUP_WIZARD, false) ?: false
}
private fun getCheckFragmentTitle() = if (viewModel.isRestoreOperation) {
getString(R.string.storage_check_fragment_restore_title)
} else {
getString(R.string.storage_check_fragment_backup_title)
}
}

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,212 @@
package com.stevesoltys.backup.ui.storage
import android.Manifest.permission.MANAGE_DOCUMENTS
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager.GET_META_DATA
import android.content.pm.ProviderInfo
import android.database.ContentObserver
import android.database.Cursor
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Handler
import android.provider.DocumentsContract
import android.provider.DocumentsContract.PROVIDER_INTERFACE
import android.provider.DocumentsContract.Root.*
import android.util.Log
import com.stevesoltys.backup.R
import java.lang.Long.parseLong
private val TAG = StorageRootFetcher::class.java.simpleName
const val AUTHORITY_STORAGE = "com.android.externalstorage.documents"
const val ROOT_ID_DEVICE = "primary"
const val ROOT_ID_HOME = "home"
const val AUTHORITY_DOWNLOADS = "com.android.providers.downloads.documents"
data class StorageRoot(
internal val authority: String,
internal val rootId: String,
internal val documentId: String,
internal val icon: Drawable?,
internal val title: String,
internal val summary: String?,
internal val availableBytes: Long?,
internal val supportsEject: Boolean,
internal val enabled: Boolean = true) {
internal val uri: Uri by lazy {
DocumentsContract.buildTreeDocumentUri(authority, documentId)
}
fun isInternal(): Boolean {
return authority == AUTHORITY_STORAGE && !supportsEject
}
}
internal interface RemovableStorageListener {
fun onStorageChanged()
}
internal class StorageRootFetcher(private val context: Context) {
private val packageManager = context.packageManager
private val contentResolver = context.contentResolver
private var listener: RemovableStorageListener? = null
private val observer = object : ContentObserver(Handler()) {
override fun onChange(selfChange: Boolean, uri: Uri?) {
super.onChange(selfChange, uri)
listener?.onStorageChanged()
}
}
internal fun setRemovableStorageListener(listener: RemovableStorageListener?) {
this.listener = listener
if (listener != null) {
val rootsUri = DocumentsContract.buildRootsUri(AUTHORITY_STORAGE)
contentResolver.registerContentObserver(rootsUri, true, observer)
} else {
contentResolver.unregisterContentObserver(observer)
}
}
internal fun getRemovableStorageListener() = listener
internal fun getStorageRoots(): List<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,98 @@
package com.stevesoltys.backup.ui.storage
import android.content.Intent
import android.content.Intent.*
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity.RESULT_OK
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import com.stevesoltys.backup.R
import com.stevesoltys.backup.ui.INTENT_EXTRA_IS_RESTORE
import com.stevesoltys.backup.ui.REQUEST_CODE_OPEN_DOCUMENT_TREE
import kotlinx.android.synthetic.main.fragment_storage_root.*
private val TAG = StorageRootsFragment::class.java.simpleName
internal class StorageRootsFragment : Fragment(), StorageRootClickedListener {
companion object {
fun newInstance(isRestore: Boolean): StorageRootsFragment {
val f = StorageRootsFragment()
f.arguments = Bundle().apply {
putBoolean(INTENT_EXTRA_IS_RESTORE, isRestore)
}
return f
}
}
private lateinit var viewModel: StorageViewModel
private val adapter by lazy { StorageRootAdapter(viewModel.isRestoreOperation, this) }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_storage_root, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
viewModel = if (arguments!!.getBoolean(INTENT_EXTRA_IS_RESTORE)) {
ViewModelProviders.of(requireActivity()).get(RestoreStorageViewModel::class.java)
} else {
ViewModelProviders.of(requireActivity()).get(BackupStorageViewModel::class.java)
}
if (viewModel.isRestoreOperation) {
titleView.text = getString(R.string.storage_fragment_restore_title)
backView.visibility = VISIBLE
backView.setOnClickListener { requireActivity().finishAfterTransition() }
} else {
warningIcon.visibility = VISIBLE
warningText.visibility = VISIBLE
divider.visibility = VISIBLE
}
listView.adapter = adapter
viewModel.storageRoots.observe(this, Observer { roots -> onRootsLoaded(roots) })
}
override fun onStart() {
super.onStart()
viewModel.loadStorageRoots()
}
private fun onRootsLoaded(roots: List<StorageRoot>) {
progressBar.visibility = INVISIBLE
adapter.setItems(roots)
}
override fun onClick(root: StorageRoot) {
viewModel.onStorageRootChosen(root)
val intent = Intent(requireContext(), PermissionGrantActivity::class.java)
intent.data = root.uri
intent.addFlags(FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION)
startActivityForResult(intent, REQUEST_CODE_OPEN_DOCUMENT_TREE)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, result: Intent?) {
if (resultCode == RESULT_OK && requestCode == REQUEST_CODE_OPEN_DOCUMENT_TREE) {
viewModel.onUriPermissionGranted(result)
} else {
super.onActivityResult(requestCode, resultCode, result)
}
}
}
internal interface StorageRootClickedListener {
fun onClick(root: StorageRoot)
}

View file

@ -0,0 +1,103 @@
package com.stevesoltys.backup.ui.storage
import android.app.Application
import android.content.Context
import android.content.Intent
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
import android.net.Uri
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.stevesoltys.backup.R
import com.stevesoltys.backup.settings.Storage
import com.stevesoltys.backup.settings.getStorage
import com.stevesoltys.backup.settings.setStorage
import com.stevesoltys.backup.transport.ConfigurableBackupTransportService
import com.stevesoltys.backup.ui.LiveEvent
import com.stevesoltys.backup.ui.MutableLiveEvent
private val TAG = StorageViewModel::class.java.simpleName
internal abstract class StorageViewModel(private val app: Application) : AndroidViewModel(app), RemovableStorageListener {
private val mStorageRoots = MutableLiveData<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) }
private var storageRoot: StorageRoot? = null
abstract val isRestoreOperation: Boolean
companion object {
internal fun validLocationIsSet(context: Context): Boolean {
val storage = getStorage(context) ?: return false
if (storage.ejectable) return true
val file = DocumentFile.fromTreeUri(context, storage.uri) ?: return false
return file.isDirectory
}
}
internal fun loadStorageRoots() {
if (storageRootFetcher.getRemovableStorageListener() == null) {
storageRootFetcher.setRemovableStorageListener(this)
}
Thread {
mStorageRoots.postValue(storageRootFetcher.getStorageRoots())
}.start()
}
override fun onStorageChanged() = loadStorageRoots()
fun onStorageRootChosen(root: StorageRoot) {
storageRoot = root
}
internal fun onUriPermissionGranted(result: Intent?) {
val uri = result?.data ?: return
// inform UI that a location has been successfully selected
mLocationSet.setEvent(true)
// persist permission to access backup folder across reboots
val takeFlags = result.flags and (FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION)
app.contentResolver.takePersistableUriPermission(uri, takeFlags)
onLocationSet(uri)
}
abstract fun onLocationSet(uri: Uri)
protected fun saveStorage(uri: Uri) {
// store backup storage location in settings
val root = storageRoot ?: throw IllegalStateException()
val name = if (root.isInternal()) {
"${root.title} (${app.getString(R.string.settings_backup_location_internal)})"
} else {
root.title
}
val storage = Storage(uri, name, root.supportsEject)
setStorage(app, storage)
// stop backup service to be sure the old location will get updated
app.stopService(Intent(app, ConfigurableBackupTransportService::class.java))
Log.d(TAG, "New storage location saved: $uri")
}
override fun onCleared() {
storageRootFetcher.setRemovableStorageListener(null)
super.onCleared()
}
}
class LocationResult(val errorMsg: String? = null)

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="M19.35,10.04C18.67,6.59 15.64,4 12,4 9.11,4 6.6,5.64 5.35,8.04 2.34,8.36 0,10.91 0,14c0,3.31 2.69,6 6,6h13c2.76,0 5,-2.24 5,-5 0,-2.64 -2.05,-4.78 -4.65,-4.96zM17,13l-5,5 -5,-5h3V9h4v4h3z" />
</vector>

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="M16,1L8,1C6.34,1 5,2.34 5,4v16c0,1.66 1.34,3 3,3h8c1.66,0 3,-1.34 3,-3L19,4c0,-1.66 -1.34,-3 -3,-3zM14,21h-4v-1h4v1zM17.25,18L6.75,18L6.75,4h10.5v14z" />
</vector>

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

@ -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="M1,21h22L12,2 1,21zM13,18h-2v-2h2v2zM13,14h-2v-4h2v4z" />
</vector>

View file

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

View file

@ -57,14 +57,14 @@
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/wordList"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:orientation="horizontal"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:layout_constraintBottom_toTopOf="@+id/confirmCodeButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/divider"
app:spanCount="6"
app:spanCount="2"
tools:itemCount="12"
tools:listitem="@layout/list_item_recovery_code_output" />

View file

@ -0,0 +1,100 @@
<?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_cloud_download"
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/restore_restoring"
android:textColor="?android:textColorSecondary"
android:textSize="24sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/imageView" />
<TextView
android:id="@+id/backupNameView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:textColor="?android:textColorTertiary"
android:textSize="18sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/titleView"
tools:text="Pixel 2 XL" />
<TextView
android:id="@+id/currentPackageView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="32dp"
android:layout_marginEnd="16dp"
android:gravity="center_horizontal"
android:textColor="?android:textColorSecondary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/backupNameView"
tools:text="@string/restore_current_package" />
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:indeterminate="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/currentPackageView" />
<TextView
android:id="@+id/warningView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="32dp"
android:layout_marginEnd="16dp"
android:textSize="18sp"
android:text="@string/restore_finished_warning_only_installed"
android:textColor="@android:color/holo_red_dark"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/progressBar"
tools:visibility="visible" />
<Button
android:id="@+id/button"
style="@style/Widget.AppCompat.Button.Colored"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:text="@string/restore_finished_button"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/warningView"
app:layout_constraintVertical_bias="1.0"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,83 @@
<?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_cloud_download"
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/restore_choose_restore_set"
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_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="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_restore_set" />
<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/errorView"
android:layout_width="0dp"
android:layout_margin="16dp"
android:layout_height="wrap_content"
android:textColor="@android:color/holo_red_dark"
android:textSize="18sp"
android:visibility="invisible"
app:layout_constraintBottom_toTopOf="@+id/backView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/titleView"
tools:text="@string/restore_set_empty_result"
tools:visibility="visible" />
<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"
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" />
</androidx.constraintlayout.widget.ConstraintLayout>

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,108 @@
<?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" />
<ImageView
android:id="@+id/warningIcon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="24dp"
android:src="@drawable/ic_warning"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/titleView"
tools:ignore="ContentDescription"
tools:visibility="visible" />
<TextView
android:id="@+id/warningText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:text="@string/storage_fragment_warning"
android:textSize="16sp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/warningIcon"
app:layout_constraintTop_toBottomOf="@+id/titleView"
tools:visibility="visible" />
<View
android:id="@+id/divider"
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_marginTop="16dp"
android:background="@color/divider"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/warningText"
tools:visibility="visible" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/listView"
android:layout_width="match_parent"
android:layout_height="0dp"
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/divider"
app:layout_goneMarginTop="16dp"
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

@ -4,7 +4,7 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="16dp"
android:padding="24dp"
tools:showIn="@layout/fragment_recovery_code_output">
<TextView

View file

@ -0,0 +1,45 @@
<?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"
tools:showIn="@layout/fragment_restore_set">
<ImageView
android:id="@+id/imageView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_phone_android"
tools:ignore="ContentDescription" />
<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/textColorSecondary"
android:textSize="18sp"
app:layout_constraintStart_toEndOf="@+id/imageView"
app:layout_constraintTop_toTopOf="parent"
tools:text="Pixel 2 XL backup" />
<TextView
android:id="@+id/subtitleView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="16dp"
android:textColor="?android:attr/textColorTertiary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="@+id/titleView"
app:layout_constraintTop_toBottomOf="@+id/titleView"
tools:text="Yesterday, 8:25 AM" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,54 @@
<?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"
android:tint="?android:attr/textColorSecondary"
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

@ -13,233 +13,244 @@
android:layout_height="wrap_content"
android:hint="@string/recovery_code_input_hint_1"
android:padding="16dp"
app:layout_constraintBottom_toTopOf="@+id/wordLayout2"
app:layout_constraintEnd_toStartOf="@+id/wordLayout7"
app:layout_constraintBottom_toTopOf="@+id/wordLayout3"
app:layout_constraintEnd_toStartOf="@+id/wordLayout2"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="spread_inside">
<com.google.android.material.textfield.TextInputEditText
<androidx.appcompat.widget.AppCompatAutoCompleteTextView
android:id="@+id/wordInput1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:completionThreshold="1"
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
android:inputType="textAutoComplete"
android:nextFocusForward="@+id/wordInput2" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/wordLayout2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/recovery_code_input_hint_2"
android:padding="16dp"
app:layout_constraintBottom_toTopOf="@+id/wordLayout3"
app:layout_constraintEnd_toStartOf="@+id/wordLayout7"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/wordLayout1">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/wordInput2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
android:inputType="textAutoComplete"
android:nextFocusForward="@+id/wordInput3" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/wordLayout3"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/recovery_code_input_hint_3"
android:padding="16dp"
app:layout_constraintBottom_toTopOf="@+id/wordLayout4"
app:layout_constraintEnd_toStartOf="@+id/wordLayout7"
app:layout_constraintBottom_toTopOf="@+id/wordLayout5"
app:layout_constraintEnd_toStartOf="@+id/wordLayout2"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/wordLayout2">
app:layout_constraintTop_toBottomOf="@+id/wordLayout1">
<com.google.android.material.textfield.TextInputEditText
<androidx.appcompat.widget.AppCompatAutoCompleteTextView
android:id="@+id/wordInput3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:completionThreshold="1"
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
android:inputType="textAutoComplete"
android:nextFocusForward="@+id/wordInput4" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/wordLayout4"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/recovery_code_input_hint_4"
android:padding="16dp"
app:layout_constraintBottom_toTopOf="@+id/wordLayout5"
app:layout_constraintEnd_toStartOf="@+id/wordLayout7"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/wordLayout3">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/wordInput4"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
android:inputType="textAutoComplete"
android:nextFocusForward="@+id/wordInput5" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/wordLayout5"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/recovery_code_input_hint_5"
android:padding="16dp"
app:layout_constraintBottom_toTopOf="@+id/wordLayout6"
app:layout_constraintEnd_toStartOf="@+id/wordLayout7"
app:layout_constraintBottom_toTopOf="@+id/wordLayout7"
app:layout_constraintEnd_toStartOf="@+id/wordLayout2"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/wordLayout4">
app:layout_constraintTop_toBottomOf="@+id/wordLayout3">
<com.google.android.material.textfield.TextInputEditText
<androidx.appcompat.widget.AppCompatAutoCompleteTextView
android:id="@+id/wordInput5"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:completionThreshold="1"
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
android:inputType="textAutoComplete"
android:nextFocusForward="@+id/wordInput6" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/wordLayout6"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/recovery_code_input_hint_6"
android:padding="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/wordLayout7"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/wordLayout5">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/wordInput6"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
android:inputType="textAutoComplete"
android:nextFocusForward="@+id/wordInput7" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/wordLayout7"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/recovery_code_input_hint_7"
android:padding="16dp"
app:layout_constraintBottom_toTopOf="@+id/wordLayout8"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/wordLayout1"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="spread_inside">
app:layout_constraintBottom_toTopOf="@+id/wordLayout9"
app:layout_constraintEnd_toStartOf="@+id/wordLayout2"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/wordLayout5">
<com.google.android.material.textfield.TextInputEditText
<androidx.appcompat.widget.AppCompatAutoCompleteTextView
android:id="@+id/wordInput7"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:completionThreshold="1"
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
android:inputType="textAutoComplete"
android:nextFocusForward="@+id/wordInput8" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/wordLayout8"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/recovery_code_input_hint_8"
android:padding="16dp"
app:layout_constraintBottom_toTopOf="@+id/wordLayout9"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/wordLayout1"
app:layout_constraintTop_toBottomOf="@+id/wordLayout7">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/wordInput8"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
android:inputType="textAutoComplete"
android:nextFocusForward="@+id/wordInput9" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/wordLayout9"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/recovery_code_input_hint_9"
android:padding="16dp"
app:layout_constraintBottom_toTopOf="@+id/wordLayout10"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/wordLayout1"
app:layout_constraintTop_toBottomOf="@+id/wordLayout8">
app:layout_constraintBottom_toTopOf="@+id/wordLayout11"
app:layout_constraintEnd_toStartOf="@+id/wordLayout2"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/wordLayout7">
<com.google.android.material.textfield.TextInputEditText
<androidx.appcompat.widget.AppCompatAutoCompleteTextView
android:id="@+id/wordInput9"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:completionThreshold="1"
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
android:inputType="textAutoComplete"
android:nextFocusForward="@+id/wordInput10" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/wordLayout10"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/recovery_code_input_hint_10"
android:padding="16dp"
app:layout_constraintBottom_toTopOf="@+id/wordLayout11"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/wordLayout1"
app:layout_constraintTop_toBottomOf="@+id/wordLayout9">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/wordInput10"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
android:inputType="textAutoComplete"
android:nextFocusForward="@+id/wordInput11" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/wordLayout11"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/recovery_code_input_hint_11"
android:padding="16dp"
app:layout_constraintBottom_toTopOf="@+id/wordLayout12"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/wordLayout1"
app:layout_constraintTop_toBottomOf="@+id/wordLayout10">
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/wordLayout2"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/wordLayout9">
<com.google.android.material.textfield.TextInputEditText
<androidx.appcompat.widget.AppCompatAutoCompleteTextView
android:id="@+id/wordInput11"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:completionThreshold="1"
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
android:inputType="textAutoComplete"
android:nextFocusForward="@+id/wordInput12" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/wordLayout2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/recovery_code_input_hint_2"
android:padding="16dp"
app:layout_constraintBottom_toTopOf="@+id/wordLayout4"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/wordLayout1"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="spread_inside">
<androidx.appcompat.widget.AppCompatAutoCompleteTextView
android:id="@+id/wordInput2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:completionThreshold="1"
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
android:inputType="textAutoComplete"
android:nextFocusForward="@+id/wordInput3" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/wordLayout4"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/recovery_code_input_hint_4"
android:padding="16dp"
app:layout_constraintBottom_toTopOf="@+id/wordLayout6"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/wordLayout1"
app:layout_constraintTop_toBottomOf="@+id/wordLayout2">
<androidx.appcompat.widget.AppCompatAutoCompleteTextView
android:id="@+id/wordInput4"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:completionThreshold="1"
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
android:inputType="textAutoComplete"
android:nextFocusForward="@+id/wordInput5" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/wordLayout6"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/recovery_code_input_hint_6"
android:padding="16dp"
app:layout_constraintBottom_toTopOf="@+id/wordLayout8"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/wordLayout1"
app:layout_constraintTop_toBottomOf="@+id/wordLayout4">
<androidx.appcompat.widget.AppCompatAutoCompleteTextView
android:id="@+id/wordInput6"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:completionThreshold="1"
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
android:inputType="textAutoComplete"
android:nextFocusForward="@+id/wordInput7" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/wordLayout8"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/recovery_code_input_hint_8"
android:padding="16dp"
app:layout_constraintBottom_toTopOf="@+id/wordLayout10"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/wordLayout1"
app:layout_constraintTop_toBottomOf="@+id/wordLayout6">
<androidx.appcompat.widget.AppCompatAutoCompleteTextView
android:id="@+id/wordInput8"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:completionThreshold="1"
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
android:inputType="textAutoComplete"
android:nextFocusForward="@+id/wordInput9" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/wordLayout10"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/recovery_code_input_hint_10"
android:padding="16dp"
app:layout_constraintBottom_toTopOf="@+id/wordLayout12"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/wordLayout1"
app:layout_constraintTop_toBottomOf="@+id/wordLayout8">
<androidx.appcompat.widget.AppCompatAutoCompleteTextView
android:id="@+id/wordInput10"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:completionThreshold="1"
android:imeOptions="actionNext|flagNavigateNext|flagNoPersonalizedLearning"
android:inputType="textAutoComplete"
android:nextFocusForward="@+id/wordInput11" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/wordLayout12"
android:layout_width="0dp"
@ -249,12 +260,13 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/wordLayout1"
app:layout_constraintTop_toBottomOf="@+id/wordLayout11">
app:layout_constraintTop_toBottomOf="@+id/wordLayout10">
<com.google.android.material.textfield.TextInputEditText
<androidx.appcompat.widget.AppCompatAutoCompleteTextView
android:id="@+id/wordInput12"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:completionThreshold="1"
android:imeOptions="actionDone|flagNoPersonalizedLearning"
android:inputType="textAutoComplete" />

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,19 +28,34 @@
<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_external_storage">External Storage</string>
<string name="settings_backup_location_invalid">The chosen location can not be used.</string>
<string name="settings_backup_location_none">None</string>
<string name="settings_backup_location_internal">Internal 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>
<string name="settings_auto_restore_title">Automatic restore</string>
<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_fragment_warning">People with access to your storage location can learn which apps you use, but do not get access to the apps\' data.</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>
<string name="recovery_code_write_it_down">Write it down on paper now!</string>
<string name="recovery_code_confirm_button">Confirm Code</string>
<string name="recovery_code_confirm_intro">Enter your 12-word recovery code to ensure that it will work when you need it.</string>
<string name="recovery_code_input_intro">Enter your 12-word recovery code that you wrote down when setting up backups.</string>
<string name="recovery_code_done_button">Done</string>
<string name="recovery_code_input_hint_1">Word 1</string>
<string name="recovery_code_input_hint_2">Word 2</string>
@ -56,11 +71,12 @@
<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>
<string name="notification_title">Backup running</string>
<string name="notification_backup_starting">Starting Backup…</string>
<string name="notification_backup_result_complete">Backup complete</string>
<string name="notification_backup_result_rejected">Not backed up</string>
<string name="notification_backup_result_error">Backup failed</string>
@ -70,4 +86,24 @@
<string name="notification_error_text">A device backup failed to run.</string>
<string name="notification_error_action">Fix</string>
<!-- Restore -->
<string name="restore_title">Restore from Backup</string>
<string name="restore_choose_restore_set">Choose a backup to restore</string>
<string name="restore_back">Don\'t restore</string>
<string name="restore_invalid_location_title">No backups found</string>
<string name="restore_invalid_location_message">We could not find any backups at this location.\n\nPlease choose another location that contains a %s folder.</string>
<string name="restore_set_error">An error occurred while loading the backups.</string>
<string name="restore_set_empty_result">No suitable backups found at given location.\n\nThis is most likely due to a wrong recovery code or a storage error.</string>
<string name="restore_restoring">Restoring Backup</string>
<string name="restore_current_package">Restoring %s…</string>
<string name="restore_finished_success">Restore complete.</string>
<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.%s</string>
<string name="restore_finished_warning_ejectable">\n\nPlease also ensure that the storage medium is plugged in when re-installing your apps.</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

@ -4,4 +4,9 @@
<item name="preferenceTheme">@style/PreferenceThemeOverlay</item>
</style>
<style name="AppTheme.NoActionBar" parent="AppTheme">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
</style>
</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>

View file

@ -10,7 +10,7 @@
app:dependency="backup"
app:icon="@drawable/ic_storage"
app:key="backup_location"
app:summary="@string/settings_backup_external_storage"
app:summary="@string/settings_backup_location_none"
app:title="@string/settings_backup_location" />
<androidx.preference.SwitchPreferenceCompat

View file

@ -0,0 +1,69 @@
package com.stevesoltys.backup.metadata
import com.stevesoltys.backup.Utf8
import com.stevesoltys.backup.crypto.Crypto
import com.stevesoltys.backup.getRandomString
import io.mockk.mockk
import org.json.JSONObject
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS
import kotlin.random.Random
@TestInstance(PER_CLASS)
class MetadataReaderTest {
private val crypto = mockk<Crypto>()
private val encoder = MetadataWriterImpl(crypto)
private val decoder = MetadataReaderImpl(crypto)
private val metadata = BackupMetadata(
version = 1.toByte(),
token = Random.nextLong(),
androidVersion = Random.nextInt(),
deviceName = getRandomString()
)
private val metadataByteArray = encoder.encode(metadata)
@Test
fun `unexpected version should throw SecurityException`() {
assertThrows(SecurityException::class.java) {
decoder.decode(metadataByteArray, 2.toByte(), metadata.token)
}
}
@Test
fun `unexpected token should throw SecurityException`() {
assertThrows(SecurityException::class.java) {
decoder.decode(metadataByteArray, metadata.version, metadata.token - 1)
}
}
@Test
fun `expected version and token do not throw SecurityException`() {
decoder.decode(metadataByteArray, metadata.version, metadata.token)
}
@Test
fun `malformed JSON throws SecurityException`() {
assertThrows(SecurityException::class.java) {
decoder.decode("{".toByteArray(Utf8), metadata.version, metadata.token)
}
}
@Test
fun `missing fields throws SecurityException`() {
val json = JSONObject()
json.put(JSON_VERSION, metadata.version.toInt())
json.put(JSON_TOKEN, metadata.token)
json.put(JSON_ANDROID_VERSION, metadata.androidVersion)
val jsonBytes = json.toString().toByteArray(Utf8)
assertThrows(SecurityException::class.java) {
decoder.decode(jsonBytes, metadata.version, metadata.token)
}
}
}

View file

@ -0,0 +1,32 @@
package com.stevesoltys.backup.metadata
import com.stevesoltys.backup.crypto.Crypto
import com.stevesoltys.backup.getRandomString
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS
import kotlin.random.Random
@TestInstance(PER_CLASS)
internal class MetadataWriterDecoderTest {
private val crypto = mockk<Crypto>()
private val encoder = MetadataWriterImpl(crypto)
private val decoder = MetadataReaderImpl(crypto)
private val metadata = BackupMetadata(
version = Random.nextBytes(1)[0],
token = Random.nextLong(),
androidVersion = Random.nextInt(),
deviceName = getRandomString()
)
@Test
fun `encoded metadata matches decoded metadata`() {
assertEquals(metadata, decoder.decode(encoder.encode(metadata), metadata.version, metadata.token))
}
}

View file

@ -14,6 +14,8 @@ import com.stevesoltys.backup.crypto.KeyManagerTestImpl
import com.stevesoltys.backup.encodeBase64
import com.stevesoltys.backup.header.HeaderReaderImpl
import com.stevesoltys.backup.header.HeaderWriterImpl
import com.stevesoltys.backup.metadata.MetadataReaderImpl
import com.stevesoltys.backup.metadata.MetadataWriterImpl
import com.stevesoltys.backup.transport.backup.*
import com.stevesoltys.backup.transport.restore.*
import io.mockk.*
@ -32,6 +34,8 @@ internal class CoordinatorIntegrationTest : TransportTest() {
private val headerWriter = HeaderWriterImpl()
private val headerReader = HeaderReaderImpl()
private val cryptoImpl = CryptoImpl(cipherFactory, headerWriter, headerReader)
private val metadataWriter = MetadataWriterImpl(cryptoImpl)
private val metadataReader = MetadataReaderImpl(cryptoImpl)
private val backupPlugin = mockk<BackupPlugin>()
private val kvBackupPlugin = mockk<KVBackupPlugin>()
@ -39,18 +43,18 @@ internal class CoordinatorIntegrationTest : TransportTest() {
private val fullBackupPlugin = mockk<FullBackupPlugin>()
private val fullBackup = FullBackup(fullBackupPlugin, inputFactory, headerWriter, cryptoImpl)
private val notificationManager = mockk<BackupNotificationManager>()
private val backup = BackupCoordinator(backupPlugin, kvBackup, fullBackup, notificationManager)
private val backup = BackupCoordinator(context, backupPlugin, kvBackup, fullBackup, metadataWriter, notificationManager)
private val restorePlugin = mockk<RestorePlugin>()
private val kvRestorePlugin = mockk<KVRestorePlugin>()
private val kvRestore = KVRestore(kvRestorePlugin, outputFactory, headerReader, cryptoImpl)
private val fullRestorePlugin = mockk<FullRestorePlugin>()
private val fullRestore = FullRestore(fullRestorePlugin, outputFactory, headerReader, cryptoImpl)
private val restore = RestoreCoordinator(restorePlugin, kvRestore, fullRestore)
private val restore = RestoreCoordinator(context, restorePlugin, kvRestore, fullRestore, metadataReader)
private val backupDataInput = mockk<BackupDataInput>()
private val fileDescriptor = mockk<ParcelFileDescriptor>(relaxed = true)
private val token = DEFAULT_RESTORE_SET_TOKEN
private val token = Random.nextLong()
private val appData = ByteArray(42).apply { Random.nextBytes(this) }
private val appData2 = ByteArray(1337).apply { Random.nextBytes(this) }
private val key = "RestoreKey"

View file

@ -1,5 +1,6 @@
package com.stevesoltys.backup.transport
import android.content.Context
import android.content.pm.PackageInfo
import android.util.Log
import com.stevesoltys.backup.crypto.Crypto
@ -13,6 +14,7 @@ import org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD
abstract class TransportTest {
protected val crypto = mockk<Crypto>()
protected val context = mockk<Context>(relaxed = true)
protected val packageInfo = PackageInfo().apply { packageName = "org.example" }

View file

@ -3,6 +3,7 @@ package com.stevesoltys.backup.transport.backup
import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_OK
import com.stevesoltys.backup.BackupNotificationManager
import com.stevesoltys.backup.metadata.MetadataWriter
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
@ -11,6 +12,7 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
import java.io.IOException
import java.io.OutputStream
import kotlin.random.Random
internal class BackupCoordinatorTest: BackupTest() {
@ -18,13 +20,17 @@ internal class BackupCoordinatorTest: BackupTest() {
private val plugin = mockk<BackupPlugin>()
private val kv = mockk<KVBackup>()
private val full = mockk<FullBackup>()
private val metadataWriter = mockk<MetadataWriter>()
private val notificationManager = mockk<BackupNotificationManager>()
private val backup = BackupCoordinator(plugin, kv, full, notificationManager)
private val backup = BackupCoordinator(context, plugin, kv, full, metadataWriter, notificationManager)
private val metadataOutputStream = mockk<OutputStream>()
@Test
fun `device initialization succeeds and delegates to plugin`() {
every { plugin.initializeDevice() } just Runs
expectWritingMetadata(0L)
every { kv.hasState() } returns false
every { full.hasState() } returns false
@ -110,4 +116,9 @@ internal class BackupCoordinatorTest: BackupTest() {
assertEquals(result, backup.finishBackup())
}
private fun expectWritingMetadata(token: Long = this.token) {
every { plugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataWriter.write(metadataOutputStream, token) } just Runs
}
}

View file

@ -1,11 +1,12 @@
package com.stevesoltys.backup.transport.backup
import android.os.ParcelFileDescriptor
import com.stevesoltys.backup.transport.TransportTest
import com.stevesoltys.backup.header.HeaderWriter
import com.stevesoltys.backup.header.VersionHeader
import com.stevesoltys.backup.transport.TransportTest
import io.mockk.mockk
import java.io.OutputStream
import kotlin.random.Random
internal abstract class BackupTest : TransportTest() {
@ -14,6 +15,7 @@ internal abstract class BackupTest : TransportTest() {
protected val data = mockk<ParcelFileDescriptor>()
protected val outputStream = mockk<OutputStream>()
protected val token = Random.nextLong()
protected val header = VersionHeader(packageName = packageInfo.packageName)
protected val quota = 42L

View file

@ -3,9 +3,12 @@ package com.stevesoltys.backup.transport.restore
import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.RestoreDescription
import android.app.backup.RestoreDescription.*
import android.app.backup.RestoreSet
import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import com.stevesoltys.backup.getRandomString
import com.stevesoltys.backup.metadata.BackupMetadata
import com.stevesoltys.backup.metadata.EncryptedBackupMetadata
import com.stevesoltys.backup.metadata.MetadataReader
import com.stevesoltys.backup.transport.TransportTest
import io.mockk.Runs
import io.mockk.every
@ -14,6 +17,7 @@ import io.mockk.mockk
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import java.io.IOException
import java.io.InputStream
import kotlin.random.Random
internal class RestoreCoordinatorTest : TransportTest() {
@ -21,30 +25,39 @@ internal class RestoreCoordinatorTest : TransportTest() {
private val plugin = mockk<RestorePlugin>()
private val kv = mockk<KVRestore>()
private val full = mockk<FullRestore>()
private val metadataReader = mockk<MetadataReader>()
private val restore = RestoreCoordinator(plugin, kv, full)
private val restore = RestoreCoordinator(context, plugin, kv, full, metadataReader)
private val token = Random.nextLong()
private val inputStream = mockk<InputStream>()
private val packageInfo2 = PackageInfo().apply { packageName = "org.example2" }
private val packageInfoArray = arrayOf(packageInfo)
private val packageInfoArray2 = arrayOf(packageInfo, packageInfo2)
@Test
fun `getAvailableRestoreSets() delegates to plugin`() {
val restoreSets = Array(1) { RestoreSet() }
fun `getAvailableRestoreSets() builds set from plugin response`() {
val encryptedMetadata = EncryptedBackupMetadata(token, inputStream)
val metadata = BackupMetadata(
token = token,
androidVersion = Random.nextInt(),
deviceName = getRandomString())
every { plugin.getAvailableRestoreSets() } returns restoreSets
every { plugin.getAvailableBackups() } returns sequenceOf(encryptedMetadata, encryptedMetadata)
every { metadataReader.readMetadata(inputStream, token) } returns metadata
every { inputStream.close() } just Runs
assertEquals(restoreSets, restore.getAvailableRestoreSets())
val sets = restore.getAvailableRestoreSets() ?: fail()
assertEquals(2, sets.size)
assertEquals(metadata.deviceName, sets[0].device)
assertEquals(metadata.deviceName, sets[0].name)
assertEquals(metadata.token, sets[0].token)
}
@Test
fun `getCurrentRestoreSet() delegates to plugin`() {
val currentRestoreSet = Random.nextLong()
every { plugin.getCurrentRestoreSet() } returns currentRestoreSet
assertEquals(currentRestoreSet, restore.getCurrentRestoreSet())
// We don't mock the SettingsManager, so the default value is returned here
assertEquals(0L, restore.getCurrentRestoreSet())
}
@Test

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<exceptions>
<exception package="com.stevesoltys.backup">
<permission name="android.permission.READ_PHONE_STATE" fixed="true"/>
</exception>
</exceptions>