commit
54ff9e8cf7
81 changed files with 2672 additions and 584 deletions
10
Android.mk
10
Android.mk
|
@ -1,13 +1,5 @@
|
|||
LOCAL_PATH := $(call my-dir)
|
||||
|
||||
include $(CLEAR_VARS)
|
||||
LOCAL_MODULE := default-permissions_com.stevesoltys.backup.xml
|
||||
LOCAL_MODULE_CLASS := ETC
|
||||
LOCAL_MODULE_TAGS := optional
|
||||
LOCAL_MODULE_PATH := $(TARGET_OUT_ETC)/default-permissions
|
||||
LOCAL_SRC_FILES := $(LOCAL_MODULE)
|
||||
include $(BUILD_PREBUILT)
|
||||
|
||||
include $(CLEAR_VARS)
|
||||
LOCAL_MODULE := permissions_com.stevesoltys.backup.xml
|
||||
LOCAL_MODULE_CLASS := ETC
|
||||
|
@ -38,4 +30,4 @@ LOCAL_MODULE_CLASS := APPS
|
|||
LOCAL_PRIVILEGED_MODULE := true
|
||||
LOCAL_DEX_PREOPT := false
|
||||
LOCAL_REQUIRED_MODULES := permissions_com.stevesoltys.backup.xml whitelist_com.stevesoltys.backup.xml
|
||||
include $(BUILD_PREBUILT)
|
||||
include $(BUILD_PREBUILT)
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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() }
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()")
|
||||
|
|
|
@ -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()
|
||||
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>?
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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>>() {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.stevesoltys.backup;
|
||||
package com.stevesoltys.backup.ui;
|
||||
|
||||
public interface LiveEventHandler<T> {
|
||||
void onEvent(T t);
|
|
@ -1,4 +1,4 @@
|
|||
package com.stevesoltys.backup
|
||||
package com.stevesoltys.backup.ui
|
||||
|
||||
class MutableLiveEvent<T> : LiveEvent<T>() {
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package com.stevesoltys.backup.settings
|
||||
package com.stevesoltys.backup.ui.recoverycode
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
|
@ -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])
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package com.stevesoltys.backup.ui.storage
|
||||
|
||||
import android.content.Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
|
||||
class PermissionGrantActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (intent?.data != null) {
|
||||
intent.addFlags(FLAG_GRANT_PREFIX_URI_PERMISSION)
|
||||
setResult(RESULT_OK, intent)
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
package com.stevesoltys.backup.ui.storage
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.View.INVISIBLE
|
||||
import android.view.View.VISIBLE
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.stevesoltys.backup.R
|
||||
import kotlinx.android.synthetic.main.fragment_storage_check.*
|
||||
|
||||
private const val TITLE = "title"
|
||||
private const val ERROR_MSG = "errorMsg"
|
||||
|
||||
class StorageCheckFragment : Fragment() {
|
||||
|
||||
companion object {
|
||||
fun newInstance(title: String, errorMsg: String? = null): StorageCheckFragment {
|
||||
val f = StorageCheckFragment()
|
||||
f.arguments = Bundle().apply {
|
||||
putString(TITLE, title)
|
||||
putString(ERROR_MSG, errorMsg)
|
||||
}
|
||||
return f
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.fragment_storage_check, container, false)
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
|
||||
titleView.text = arguments!!.getString(TITLE)
|
||||
|
||||
val errorMsg = arguments!!.getString(ERROR_MSG)
|
||||
if (errorMsg != null) {
|
||||
progressBar.visibility = INVISIBLE
|
||||
errorView.text = errorMsg
|
||||
errorView.visibility = VISIBLE
|
||||
backButton.visibility = VISIBLE
|
||||
backButton.setOnClickListener { requireActivity().supportFinishAfterTransition() }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,97 @@
|
|||
package com.stevesoltys.backup.ui.storage
|
||||
|
||||
|
||||
import android.content.Context
|
||||
import android.text.format.Formatter
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.View.GONE
|
||||
import android.view.View.VISIBLE
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.recyclerview.widget.RecyclerView.Adapter
|
||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||
import com.stevesoltys.backup.R
|
||||
import com.stevesoltys.backup.ui.storage.StorageRootAdapter.StorageRootViewHolder
|
||||
|
||||
internal class StorageRootAdapter(
|
||||
private val isRestore: Boolean,
|
||||
private val listener: StorageRootClickedListener) : Adapter<StorageRootViewHolder>() {
|
||||
|
||||
private val items = ArrayList<StorageRoot>()
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StorageRootViewHolder {
|
||||
val v = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.list_item_storage_root, parent, false) as View
|
||||
return StorageRootViewHolder(v)
|
||||
}
|
||||
|
||||
override fun getItemCount() = items.size
|
||||
|
||||
override fun onBindViewHolder(holder: StorageRootViewHolder, position: Int) {
|
||||
holder.bind(items[position])
|
||||
}
|
||||
|
||||
internal fun setItems(items: List<StorageRoot>) {
|
||||
this.items.clear()
|
||||
this.items.addAll(items)
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
internal inner class StorageRootViewHolder(private val v: View) : ViewHolder(v) {
|
||||
|
||||
private val iconView = v.findViewById<ImageView>(R.id.iconView)
|
||||
private val titleView = v.findViewById<TextView>(R.id.titleView)
|
||||
private val summaryView = v.findViewById<TextView>(R.id.summaryView)
|
||||
|
||||
internal fun bind(item: StorageRoot) {
|
||||
if (item.enabled) {
|
||||
v.isEnabled = true
|
||||
v.alpha = 1f
|
||||
} else {
|
||||
v.isEnabled = false
|
||||
v.alpha = 0.3f
|
||||
}
|
||||
|
||||
iconView.setImageDrawable(item.icon)
|
||||
titleView.text = item.title
|
||||
when {
|
||||
item.summary != null -> {
|
||||
summaryView.text = item.summary
|
||||
summaryView.visibility = VISIBLE
|
||||
}
|
||||
item.availableBytes != null -> {
|
||||
val str = Formatter.formatFileSize(v.context, item.availableBytes)
|
||||
summaryView.text = v.context.getString(R.string.storage_available_bytes, str)
|
||||
summaryView.visibility = VISIBLE
|
||||
}
|
||||
else -> summaryView.visibility = GONE
|
||||
}
|
||||
v.setOnClickListener {
|
||||
if (!isRestore && item.isInternal()) {
|
||||
showWarningDialog(v.context, item)
|
||||
} else {
|
||||
listener.onClick(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun showWarningDialog(context: Context, item: StorageRoot) {
|
||||
AlertDialog.Builder(context)
|
||||
.setTitle(R.string.storage_internal_warning_title)
|
||||
.setMessage(R.string.storage_internal_warning_message)
|
||||
.setPositiveButton(R.string.storage_internal_warning_choose_other) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
}
|
||||
.setNegativeButton(R.string.storage_internal_warning_use_anyway) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
listener.onClick(item)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,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
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
10
app/src/main/res/drawable/ic_cloud_download.xml
Normal file
10
app/src/main/res/drawable/ic_cloud_download.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?android:attr/textColorSecondary"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="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>
|
10
app/src/main/res/drawable/ic_phone_android.xml
Normal file
10
app/src/main/res/drawable/ic_phone_android.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?android:attr/textColorSecondary"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="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>
|
10
app/src/main/res/drawable/ic_usb.xml
Normal file
10
app/src/main/res/drawable/ic_usb.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?android:attr/textColorSecondary"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M15,7v4h1v2h-3V5h2l-3,-4 -3,4h2v8H8v-2.07c0.7,-0.37 1.2,-1.08 1.2,-1.93 0,-1.21 -0.99,-2.2 -2.2,-2.2 -1.21,0 -2.2,0.99 -2.2,2.2 0,0.85 0.5,1.56 1.2,1.93V13c0,1.11 0.89,2 2,2h3v3.05c-0.71,0.37 -1.2,1.1 -1.2,1.95 0,1.22 0.99,2.2 2.2,2.2 1.21,0 2.2,-0.98 2.2,-2.2 0,-0.85 -0.49,-1.58 -1.2,-1.95V15h3c1.11,0 2,-0.89 2,-2v-2h1V7h-4z" />
|
||||
</vector>
|
10
app/src/main/res/drawable/ic_warning.xml
Normal file
10
app/src/main/res/drawable/ic_warning.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?android:attr/textColorSecondary"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M1,21h22L12,2 1,21zM13,18h-2v-2h2v2zM13,14h-2v-4h2v4z" />
|
||||
</vector>
|
|
@ -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"
|
||||
|
|
|
@ -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" />
|
||||
|
||||
|
|
100
app/src/main/res/layout/fragment_restore_progress.xml
Normal file
100
app/src/main/res/layout/fragment_restore_progress.xml
Normal 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>
|
83
app/src/main/res/layout/fragment_restore_set.xml
Normal file
83
app/src/main/res/layout/fragment_restore_set.xml
Normal 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>
|
76
app/src/main/res/layout/fragment_storage_check.xml
Normal file
76
app/src/main/res/layout/fragment_storage_check.xml
Normal file
|
@ -0,0 +1,76 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imageView"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_margin="16dp"
|
||||
android:tint="?android:colorAccent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:srcCompat="@drawable/ic_storage"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/titleView"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="16dp"
|
||||
android:gravity="center_horizontal"
|
||||
android:textSize="24sp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/imageView"
|
||||
tools:text="@string/storage_check_fragment_backup_title" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressBar"
|
||||
style="?android:progressBarStyleLarge"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="32dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/titleView"
|
||||
app:layout_constraintVertical_bias="0.0" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/errorView"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="32dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:textColor="@android:color/holo_red_dark"
|
||||
android:textSize="18sp"
|
||||
android:visibility="invisible"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/titleView"
|
||||
app:layout_constraintVertical_bias="0.0"
|
||||
tools:text="@string/storage_check_fragment_backup_error"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/backButton"
|
||||
style="@style/Widget.AppCompat.Button.Colored"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="16dp"
|
||||
android:text="@string/storage_check_fragment_error_button"
|
||||
android:visibility="invisible"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
tools:visibility="visible" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
108
app/src/main/res/layout/fragment_storage_root.xml
Normal file
108
app/src/main/res/layout/fragment_storage_root.xml
Normal 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>
|
|
@ -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
|
||||
|
|
45
app/src/main/res/layout/list_item_restore_set.xml
Normal file
45
app/src/main/res/layout/list_item_restore_set.xml
Normal 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>
|
54
app/src/main/res/layout/list_item_storage_root.xml
Normal file
54
app/src/main/res/layout/list_item_storage_root.xml
Normal 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>
|
|
@ -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" />
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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" }
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
Loading…
Reference in a new issue