Merge pull request #42 from grote/usb

Various optimizations for using a USB flash drive as backup storage medium
This commit is contained in:
Steve Soltys 2019-09-25 01:06:17 -04:00 committed by GitHub
commit d944f985a0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 604 additions and 179 deletions

View file

@ -3,8 +3,6 @@ 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.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
@ -21,9 +19,8 @@ private const val filename = "test-file"
class DocumentsStorageTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val token = getBackupToken(context)
private val folderUri = getStorage(context)
private val storage = DocumentsStorage(context, folderUri, token)
private val settingsManager = (context.applicationContext as Backup).settingsManager
private val storage = DocumentsStorage(context, settingsManager)
private lateinit var file: DocumentFile

View file

@ -14,6 +14,16 @@
android:name="android.permission.MANAGE_DOCUMENTS"
tools:ignore="ProtectedPermissions" />
<!-- This is needed to access the serial number of USB mass storage devices -->
<uses-permission
android:name="android.permission.MANAGE_USB"
tools:ignore="ProtectedPermissions" />
<!-- This is needed to change system backup settings -->
<uses-permission
android:name="android.permission.WRITE_SECURE_SETTINGS"
tools:ignore="ProtectedPermissions" />
<application
android:name=".Backup"
android:allowBackup="false"
@ -72,5 +82,16 @@
</intent-filter>
</service>
<receiver
android:name=".UsbIntentReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>
<meta-data
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
android:resource="@xml/device_filter" />
</receiver>
</application>
</manifest>

View file

@ -1,14 +1,14 @@
package com.stevesoltys.backup
import android.app.Application
import android.app.backup.BackupManager.PACKAGE_MANAGER_SENTINEL
import android.app.backup.IBackupManager
import android.content.Context.BACKUP_SERVICE
import android.net.Uri
import android.os.Build
import android.os.ServiceManager.getService
import com.stevesoltys.backup.crypto.KeyManager
import com.stevesoltys.backup.crypto.KeyManagerImpl
import com.stevesoltys.backup.ui.storage.AUTHORITY_STORAGE
import com.stevesoltys.backup.settings.SettingsManager
/**
* @author Steve Soltys
@ -25,10 +25,15 @@ class Backup : Application() {
}
}
val settingsManager by lazy {
SettingsManager(this)
}
val notificationManager by lazy {
BackupNotificationManager(this)
}
}
const val MAGIC_PACKAGE_MANAGER = PACKAGE_MANAGER_SENTINEL
fun isDebugBuild() = Build.TYPE == "userdebug"

View file

@ -79,9 +79,9 @@ class BackupNotificationManager(private val context: Context) {
val notification = errorBuilder.apply {
setContentTitle(context.getString(R.string.notification_error_title))
setContentText(context.getString(R.string.notification_error_text))
addAction(action)
setOnlyAlertOnce(true)
setAutoCancel(true)
mActions = arrayListOf(action)
}.build()
nm.notify(NOTIFICATION_ID_ERROR, notification)
}

View file

@ -68,7 +68,7 @@ class NotificationBackupObserver(context: Context, private val userInitiated: Bo
}
fun getAppName(pm: PackageManager, packageId: String): CharSequence {
if (packageId == "@pm@") return packageId
if (packageId == MAGIC_PACKAGE_MANAGER) return packageId
val appInfo = pm.getApplicationInfo(packageId, 0)
return pm.getApplicationLabel(appInfo)
}

View file

@ -0,0 +1,104 @@
package com.stevesoltys.backup
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.database.ContentObserver
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager.*
import android.net.Uri
import android.os.Handler
import android.provider.DocumentsContract
import android.util.Log
import com.stevesoltys.backup.settings.FlashDrive
import com.stevesoltys.backup.transport.requestBackup
import com.stevesoltys.backup.ui.storage.AUTHORITY_STORAGE
import java.util.*
import java.util.concurrent.TimeUnit.HOURS
private val TAG = UsbIntentReceiver::class.java.simpleName
class UsbIntentReceiver : UsbMonitor() {
override fun shouldMonitorStatus(context: Context, action: String, device: UsbDevice): Boolean {
if (action != ACTION_USB_DEVICE_ATTACHED) return false
Log.d(TAG, "Checking if this is the current backup drive.")
val settingsManager = (context.applicationContext as Backup).settingsManager
val savedFlashDrive = settingsManager.getFlashDrive() ?: return false
val attachedFlashDrive = FlashDrive.from(device)
return if (savedFlashDrive == attachedFlashDrive) {
Log.d(TAG, "Matches stored device, checking backup time...")
if (Date().time - settingsManager.getBackupTime() >= HOURS.toMillis(24)) {
Log.d(TAG, "Last backup older than 24 hours, requesting a backup...")
true
} else {
Log.d(TAG, "We have a recent backup, not requesting a new one.")
false
}
} else {
Log.d(TAG, "Different device attached, ignoring...")
false
}
}
override fun onStatusChanged(context: Context, action: String, device: UsbDevice) {
Thread {
requestBackup(context)
}.start()
}
}
/**
* When we get the [ACTION_USB_DEVICE_ATTACHED] broadcast, the storage is not yet available.
* So we need to use a ContentObserver to request a backup only once available.
*/
abstract class UsbMonitor : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action ?: return
if (intent.action == ACTION_USB_DEVICE_ATTACHED || intent.action == ACTION_USB_DEVICE_DETACHED) {
val device = intent.extras?.getParcelable<UsbDevice>(EXTRA_DEVICE) ?: return
Log.d(TAG, "New USB mass-storage device attached.")
device.log()
if (!shouldMonitorStatus(context, action, device)) return
val rootsUri = DocumentsContract.buildRootsUri(AUTHORITY_STORAGE)
val contentResolver = context.contentResolver
val observer = object : ContentObserver(Handler()) {
override fun onChange(selfChange: Boolean, uri: Uri?) {
super.onChange(selfChange, uri)
onStatusChanged(context, action, device)
contentResolver.unregisterContentObserver(this)
}
}
contentResolver.registerContentObserver(rootsUri, true, observer)
}
}
abstract fun shouldMonitorStatus(context: Context, action: String, device: UsbDevice): Boolean
abstract fun onStatusChanged(context: Context, action: String, device: UsbDevice)
}
internal fun UsbDevice.isMassStorage(): Boolean {
for (i in 0 until interfaceCount) {
if (getInterface(i).isMassStorage()) return true
}
return false
}
private fun UsbInterface.isMassStorage(): Boolean {
return interfaceClass == 8 && interfaceProtocol == 80 && interfaceSubclass == 6
}
private fun UsbDevice.log() {
Log.d(TAG, " name: $manufacturerName $productName")
Log.d(TAG, " serialNumber: $serialNumber")
Log.d(TAG, " productId: $productId")
Log.d(TAG, " vendorId: $vendorId")
Log.d(TAG, " isMassStorage: ${isMassStorage()}")
}

View file

@ -18,8 +18,6 @@ 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.getBackupPassword;
import static com.stevesoltys.backup.settings.SettingsManagerKt.getStorage;
/**
* @author Steve Soltys
@ -41,7 +39,7 @@ public class MainActivityController {
}
boolean isChangeBackupLocationButtonVisible(Activity parent) {
return getStorage(parent) != null;
return false;
}
private void showChooseFolderActivity(Activity parent, boolean continueToBackup) {
@ -74,15 +72,8 @@ public class MainActivityController {
}
boolean onAutomaticBackupsButtonClicked(Activity parent) {
if (getStorage(parent) == null || getBackupPassword(parent) == null) {
Toast.makeText(parent, "Please make at least one manual backup first.", Toast.LENGTH_SHORT).show();
return false;
}
// show Toast informing the user
Toast.makeText(parent, "REMOVED", Toast.LENGTH_SHORT).show();
return true;
Toast.makeText(parent, "Please make at least one manual backup first.", Toast.LENGTH_SHORT).show();
return false;
}
void onChangeBackupLocationButtonClicked(Activity parent) {

View file

@ -21,8 +21,6 @@ import com.stevesoltys.backup.service.backup.BackupService;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import static com.stevesoltys.backup.settings.SettingsManagerKt.getBackupPassword;
/**
* @author Steve Soltys
*/
@ -70,12 +68,7 @@ class CreateBackupActivityController {
}
void onCreateBackupButtonClicked(Set<String> selectedPackages, Activity parent) {
String password = getBackupPassword(parent);
if (password == null) {
showEnterPasswordAlert(selectedPackages, parent);
} else {
backupService.backupPackageData(selectedPackages, parent);
}
backupService.backupPackageData(selectedPackages, parent);
}
private void showEnterPasswordAlert(Set<String> selectedPackages, Activity parent) {

View file

@ -10,10 +10,10 @@ 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.Backup
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() {
@ -49,7 +49,8 @@ class RestoreProgressFragment : Fragment() {
if (finished == 0) {
// success
currentPackageView.text = getString(R.string.restore_finished_success)
warningView.text = if (getStorage(requireContext())?.ejectable == true) {
val settingsManager = (requireContext().applicationContext as Backup).settingsManager
warningView.text = if (settingsManager.getStorage()?.isUsb == 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)

View file

@ -8,6 +8,7 @@ import android.os.UserHandle
import android.util.Log
import com.google.android.collect.Sets.newArraySet
import com.stevesoltys.backup.Backup
import com.stevesoltys.backup.MAGIC_PACKAGE_MANAGER
import java.util.*
private val TAG = PackageService::class.java.simpleName
@ -49,7 +50,7 @@ class PackageService {
// add magic @pm@ package (PACKAGE_MANAGER_SENTINEL) which holds package manager data
val packageArray = eligibleApps.toMutableList()
packageArray.add("@pm@")
packageArray.add(MAGIC_PACKAGE_MANAGER)
return packageArray.toTypedArray()
}

View file

@ -0,0 +1,38 @@
package com.stevesoltys.backup.settings
import android.content.ContentResolver
import android.provider.Settings
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeUnit.DAYS
private val SETTING = Settings.Secure.BACKUP_MANAGER_CONSTANTS
private const val DELIMITER = ','
private const val KEY_VALUE_BACKUP_INTERVAL_MILLISECONDS = "key_value_backup_interval_milliseconds"
private const val FULL_BACKUP_INTERVAL_MILLISECONDS = "full_backup_interval_milliseconds"
object BackupManagerSettings {
/**
* This clears the backup settings, so that default values will be used.
*/
fun enableAutomaticBackups(resolver: ContentResolver) {
// setting this to null will cause the BackupManagerConstants to use default values
setSettingValue(resolver, null)
}
/**
* This sets the backup intervals to a longer than default value. Currently 30 days
*/
fun disableAutomaticBackups(resolver: ContentResolver) {
val value = DAYS.toMillis(30)
val kv = "$KEY_VALUE_BACKUP_INTERVAL_MILLISECONDS=$value"
val full = "$FULL_BACKUP_INTERVAL_MILLISECONDS=$value"
setSettingValue(resolver, "$kv$DELIMITER$full")
}
private fun setSettingValue(resolver: ContentResolver, value: String?) {
Settings.Secure.putString(resolver, SETTING, value)
}
}

View file

@ -1,11 +1,18 @@
package com.stevesoltys.backup.settings
import android.content.Context
import android.content.Context.BACKUP_SERVICE
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager.ACTION_USB_DEVICE_ATTACHED
import android.hardware.usb.UsbManager.ACTION_USB_DEVICE_DETACHED
import android.os.Bundle
import android.os.RemoteException
import android.provider.Settings
import android.provider.Settings.Secure.BACKUP_AUTO_RESTORE
import android.text.format.DateUtils.MINUTE_IN_MILLIS
import android.text.format.DateUtils.getRelativeTimeSpanString
import android.util.Log
import android.view.Menu
import android.view.MenuInflater
@ -17,7 +24,10 @@ import androidx.preference.PreferenceFragmentCompat
import androidx.preference.TwoStatePreference
import com.stevesoltys.backup.Backup
import com.stevesoltys.backup.R
import com.stevesoltys.backup.UsbMonitor
import com.stevesoltys.backup.isMassStorage
import com.stevesoltys.backup.restore.RestoreActivity
import java.util.*
private val TAG = SettingsFragment::class.java.name
@ -26,16 +36,35 @@ class SettingsFragment : PreferenceFragmentCompat() {
private val backupManager = Backup.backupManager
private lateinit var viewModel: SettingsViewModel
private lateinit var settingsManager: SettingsManager
private lateinit var backup: TwoStatePreference
private lateinit var autoRestore: TwoStatePreference
private lateinit var backupLocation: Preference
private var menuBackupNow: MenuItem? = null
private var menuRestore: MenuItem? = null
private var storage: Storage? = null
private val usbFilter = IntentFilter(ACTION_USB_DEVICE_ATTACHED).apply {
addAction(ACTION_USB_DEVICE_DETACHED)
}
private val usbReceiver = object : UsbMonitor() {
override fun shouldMonitorStatus(context: Context, action: String, device: UsbDevice): Boolean {
return device.isMassStorage()
}
override fun onStatusChanged(context: Context, action: String, device: UsbDevice) {
setMenuItemStates()
}
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings, rootKey)
setHasOptionsMenu(true)
viewModel = ViewModelProviders.of(requireActivity()).get(SettingsViewModel::class.java)
settingsManager = (requireContext().applicationContext as Backup).settingsManager
backup = findPreference<TwoStatePreference>("backup")!!
backup.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
@ -74,30 +103,31 @@ class SettingsFragment : PreferenceFragmentCompat() {
super.onStart()
// we need to re-set the title when returning to this fragment
requireActivity().setTitle(R.string.app_name)
activity?.setTitle(R.string.app_name)
try {
backup.isChecked = backupManager.isBackupEnabled
backup.isEnabled = true
} catch (e: RemoteException) {
Log.e(TAG, "Error communicating with BackupManager", e)
backup.isEnabled = false
}
storage = settingsManager.getStorage()
setBackupState()
setAutoRestoreState()
setBackupLocationSummary()
setMenuItemStates()
val resolver = requireContext().contentResolver
autoRestore.isChecked = Settings.Secure.getInt(resolver, BACKUP_AUTO_RESTORE, 1) == 1
if (storage?.isUsb == true) context?.registerReceiver(usbReceiver, usbFilter)
}
// TODO add time of last backup here
val storageName = getStorage(requireContext())?.name
backupLocation.summary = storageName ?: getString(R.string.settings_backup_location_none )
override fun onStop() {
super.onStop()
if (storage?.isUsb == true) context?.unregisterReceiver(usbReceiver)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
inflater.inflate(R.menu.settings_menu, menu)
menuBackupNow = menu.findItem(R.id.action_backup)
menuRestore = menu.findItem(R.id.action_restore)
if (resources.getBoolean(R.bool.show_restore_in_settings)) {
menu.findItem(R.id.action_restore).isVisible = true
menuRestore?.isVisible = true
}
setMenuItemStates()
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when {
@ -112,4 +142,45 @@ class SettingsFragment : PreferenceFragmentCompat() {
else -> super.onOptionsItemSelected(item)
}
private fun setBackupState() {
try {
backup.isChecked = backupManager.isBackupEnabled
backup.isEnabled = true
} catch (e: RemoteException) {
Log.e(TAG, "Error communicating with BackupManager", e)
backup.isEnabled = false
}
}
private fun setAutoRestoreState() {
activity?.contentResolver?.let {
autoRestore.isChecked = Settings.Secure.getInt(it, BACKUP_AUTO_RESTORE, 1) == 1
}
}
private fun setBackupLocationSummary() {
// get name of storage location
val storageName = storage?.name ?: getString(R.string.settings_backup_location_none)
// get time of last backup
val lastBackupInMillis = settingsManager.getBackupTime()
val lastBackup = if (lastBackupInMillis == 0L) {
getString(R.string.settings_backup_last_backup_never)
} else {
getRelativeTimeSpanString(lastBackupInMillis, Date().time, MINUTE_IN_MILLIS, 0)
}
backupLocation.summary = getString(R.string.settings_backup_location_summary, storageName, lastBackup)
}
private fun setMenuItemStates() {
val context = context ?: return
if (menuBackupNow != null && menuRestore != null) {
val storage = this.storage
val enabled = storage != null &&
(!storage.isUsb || storage.getDocumentFile(context).isDirectory)
menuBackupNow?.isEnabled = enabled
menuRestore?.isEnabled = enabled
}
}
}

View file

@ -1,59 +1,139 @@
package com.stevesoltys.backup.settings
import android.content.Context
import android.hardware.usb.UsbDevice
import android.net.Uri
import android.preference.PreferenceManager.getDefaultSharedPreferences
import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager
import java.util.*
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_STORAGE_IS_USB = "storageIsUsb"
private const val PREF_KEY_FLASH_DRIVE_NAME = "flashDriveName"
private const val PREF_KEY_FLASH_DRIVE_SERIAL_NUMBER = "flashSerialNumber"
private const val PREF_KEY_FLASH_DRIVE_VENDOR_ID = "flashDriveVendorId"
private const val PREF_KEY_FLASH_DRIVE_PRODUCT_ID = "flashDriveProductId"
private const val PREF_KEY_BACKUP_TOKEN = "backupToken"
private const val PREF_KEY_BACKUP_TIME = "backupTime"
private const val PREF_KEY_BACKUP_PASSWORD = "backupLegacyPassword"
class SettingsManager(context: Context) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
fun setStorage(storage: Storage) {
prefs.edit()
.putString(PREF_KEY_STORAGE_URI, storage.uri.toString())
.putString(PREF_KEY_STORAGE_NAME, storage.name)
.putBoolean(PREF_KEY_STORAGE_IS_USB, storage.isUsb)
.apply()
}
fun getStorage(): Storage? {
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 isUsb = prefs.getBoolean(PREF_KEY_STORAGE_IS_USB, false)
return Storage(uri, name, isUsb)
}
fun setFlashDrive(usb: FlashDrive?) {
if (usb == null) {
prefs.edit()
.remove(PREF_KEY_FLASH_DRIVE_NAME)
.remove(PREF_KEY_FLASH_DRIVE_SERIAL_NUMBER)
.remove(PREF_KEY_FLASH_DRIVE_VENDOR_ID)
.remove(PREF_KEY_FLASH_DRIVE_PRODUCT_ID)
.apply()
} else {
prefs.edit()
.putString(PREF_KEY_FLASH_DRIVE_NAME, usb.name)
.putString(PREF_KEY_FLASH_DRIVE_SERIAL_NUMBER, usb.serialNumber)
.putInt(PREF_KEY_FLASH_DRIVE_VENDOR_ID, usb.vendorId)
.putInt(PREF_KEY_FLASH_DRIVE_PRODUCT_ID, usb.productId)
.apply()
}
}
fun getFlashDrive(): FlashDrive? {
val name = prefs.getString(PREF_KEY_FLASH_DRIVE_NAME, null) ?: return null
val serialNumber = prefs.getString(PREF_KEY_FLASH_DRIVE_SERIAL_NUMBER, null)
val vendorId = prefs.getInt(PREF_KEY_FLASH_DRIVE_VENDOR_ID, -1)
val productId = prefs.getInt(PREF_KEY_FLASH_DRIVE_PRODUCT_ID, -1)
return FlashDrive(name, serialNumber, vendorId, productId)
}
/**
* 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(): Long = Date().time.apply {
prefs.edit()
.putLong(PREF_KEY_BACKUP_TOKEN, this)
.apply()
}
/**
* Returns the current backup token or 0 if none exists.
*/
fun getBackupToken(): Long {
return prefs.getLong(PREF_KEY_BACKUP_TOKEN, 0L)
}
/**
* Sets the last backup time to "now".
*/
fun saveNewBackupTime() {
prefs.edit()
.putLong(PREF_KEY_BACKUP_TIME, Date().time)
.apply()
}
/**
* Sets the last backup time to "never".
*/
fun resetBackupTime() {
prefs.edit()
.putLong(PREF_KEY_BACKUP_TIME, 0L)
.apply()
}
/**
* Returns the last backup time in unix epoch milli seconds.
*/
fun getBackupTime(): Long {
return prefs.getLong(PREF_KEY_BACKUP_TIME, 0L)
}
@Deprecated("Replaced by KeyManager#getBackupKey()")
fun getBackupPassword(): String? {
return prefs.getString(PREF_KEY_BACKUP_PASSWORD, null)
}
}
data class Storage(
val uri: Uri,
val name: String,
val ejectable: Boolean
)
fun setStorage(context: Context, storage: Storage) {
getDefaultSharedPreferences(context)
.edit()
.putString(PREF_KEY_STORAGE_URI, storage.uri.toString())
.putString(PREF_KEY_STORAGE_NAME, storage.name)
.putBoolean(PREF_KEY_STORAGE_EJECTABLE, storage.ejectable)
.apply()
val isUsb: Boolean) {
fun getDocumentFile(context: Context) = DocumentFile.fromTreeUri(context, uri)
?: throw AssertionError("Should only happen on API < 21.")
}
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)
}
/**
* 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()
.putLong(PREF_KEY_BACKUP_TOKEN, this)
.apply()
}
/**
* 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()")
fun getBackupPassword(context: Context): String? {
return getDefaultSharedPreferences(context).getString(PREF_KEY_BACKUP_PASSWORD, null)
data class FlashDrive(
val name: String,
val serialNumber: String?,
val vendorId: Int,
val productId: Int) {
companion object {
fun from(device: UsbDevice) = FlashDrive(
name = "${device.manufacturerName} ${device.productName}",
serialNumber = device.serialNumber,
vendorId = device.vendorId,
productId = device.productId
)
}
}

View file

@ -1,20 +1,14 @@
package com.stevesoltys.backup.settings
import android.app.Application
import com.stevesoltys.backup.Backup
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(app: Application) : RequireProvisioningViewModel(app) {
override val isRestoreOperation = false
fun backupNow() {
val nm = (app as Backup).notificationManager
nm.onBackupUpdate(app.getString(R.string.notification_backup_starting), 0, 1, true)
Thread { requestBackup(app) }.start()
}

View file

@ -7,7 +7,6 @@ import android.app.backup.RestoreSet
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInfo
import android.os.Build.VERSION.SDK_INT
import android.os.ParcelFileDescriptor
import android.util.Log
import com.stevesoltys.backup.settings.SettingsActivity
@ -36,7 +35,7 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
}
override fun getTransportFlags(): Int {
return if (SDK_INT >= 28) FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED else 0
return FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED
}
override fun dataManagementIntent(): Intent {

View file

@ -14,6 +14,7 @@ import android.util.Log
import androidx.annotation.WorkerThread
import com.stevesoltys.backup.Backup
import com.stevesoltys.backup.NotificationBackupObserver
import com.stevesoltys.backup.R
import com.stevesoltys.backup.service.PackageService
import com.stevesoltys.backup.session.backup.BackupMonitor
@ -50,7 +51,10 @@ class ConfigurableBackupTransportService : Service() {
@WorkerThread
fun requestBackup(context: Context) {
context.startService(Intent(context, ConfigurableBackupTransportService::class.java))
// show notification
val nm = (context.applicationContext as Backup).notificationManager
nm.onBackupUpdate(context.getString(R.string.notification_backup_starting), 0, 1, true)
val observer = NotificationBackupObserver(context, true)
val flags = FLAG_NON_INCREMENTAL_BACKUP or FLAG_USER_INITIATED
val packages = PackageService().eligiblePackages

View file

@ -8,8 +8,6 @@ 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.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,9 +22,10 @@ import com.stevesoltys.backup.transport.restore.plugins.DocumentsProviderRestore
class PluginManager(context: Context) {
// We can think about using an injection framework such as Dagger to simplify this.
// We can think about using an injection framework such as Dagger, Koin or Kodein to simplify this.
private val storage = DocumentsStorage(context, getStorage(context), getBackupToken(context))
private val settingsManager = (context.applicationContext as Backup).settingsManager
private val storage = DocumentsStorage(context, settingsManager)
private val headerWriter = HeaderWriterImpl()
private val headerReader = HeaderReaderImpl()
@ -42,7 +41,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(context, backupPlugin, kvBackup, fullBackup, metadataWriter, notificationManager)
internal val backupCoordinator = BackupCoordinator(context, backupPlugin, kvBackup, fullBackup, metadataWriter, settingsManager, notificationManager)
private val restorePlugin = DocumentsProviderRestorePlugin(storage)
@ -50,6 +49,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(context, restorePlugin, kvRestore, fullRestore, metadataReader)
internal val restoreCoordinator = RestoreCoordinator(settingsManager, restorePlugin, kvRestore, fullRestore, metadataReader)
}

View file

@ -1,15 +1,16 @@
package com.stevesoltys.backup.transport.backup
import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.BackupTransport.*
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.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.backup.metadata.MetadataWriter
import com.stevesoltys.backup.settings.getBackupToken
import com.stevesoltys.backup.settings.SettingsManager
import java.io.IOException
import java.util.concurrent.TimeUnit.DAYS
private val TAG = BackupCoordinator::class.java.simpleName
@ -23,6 +24,7 @@ class BackupCoordinator(
private val kv: KVBackup,
private val full: FullBackup,
private val metadataWriter: MetadataWriter,
private val settingsManager: SettingsManager,
private val nm: BackupNotificationManager) {
private var calledInitialize = false
@ -54,14 +56,15 @@ class BackupCoordinator(
Log.i(TAG, "Initialize Device!")
return try {
plugin.initializeDevice()
writeBackupMetadata(getBackupToken(context))
writeBackupMetadata(settingsManager.getBackupToken())
// [finishBackup] will only be called when we return [TRANSPORT_OK] here
// so we remember that we initialized successfully
calledInitialize = true
TRANSPORT_OK
} catch (e: IOException) {
Log.e(TAG, "Error initializing device", e)
nm.onBackupError()
// Show error notification if we were ready for backups
if (getBackupBackoff() == 0L) nm.onBackupError()
TRANSPORT_ERROR
}
}
@ -83,21 +86,61 @@ class BackupCoordinator(
// Key/value incremental backup support
//
fun requestBackupTime() = kv.requestBackupTime()
/**
* Verify that this is a suitable time for a key/value backup pass.
* This should return zero if a backup is reasonable right now, some positive value otherwise.
* This method will be called outside of the [performIncrementalBackup]/[finishBackup] pair.
*
* If this is not a suitable time for a backup, the transport should return a backoff delay,
* in milliseconds, after which the Backup Manager should try again.
*
* @return Zero if this is a suitable time for a backup pass, or a positive time delay
* in milliseconds to suggest deferring the backup pass for a while.
*/
fun requestBackupTime(): Long = getBackupBackoff().apply {
Log.i(TAG, "Request incremental backup time. Returned $this")
}
fun performIncrementalBackup(packageInfo: PackageInfo, data: ParcelFileDescriptor, flags: Int) =
kv.performBackup(packageInfo, data, flags)
fun performIncrementalBackup(packageInfo: PackageInfo, data: ParcelFileDescriptor, flags: Int): Int {
// backups of package manager metadata do not respect backoff
// we need to reject them manually when now is not a good time for a backup
if (packageInfo.packageName == MAGIC_PACKAGE_MANAGER && getBackupBackoff() != 0L) {
return TRANSPORT_PACKAGE_REJECTED
}
val result = kv.performBackup(packageInfo, data, flags)
if (result == TRANSPORT_OK) settingsManager.saveNewBackupTime()
return result
}
// ------------------------------------------------------------------------------------
// Full backup
//
fun requestFullBackupTime() = full.requestFullBackupTime()
/**
* Verify that this is a suitable time for a full-data backup pass.
* This should return zero if a backup is reasonable right now, some positive value otherwise.
* This method will be called outside of the [performFullBackup]/[finishBackup] pair.
*
* If this is not a suitable time for a backup, the transport should return a backoff delay,
* in milliseconds, after which the Backup Manager should try again.
*
* @return Zero if this is a suitable time for a backup pass, or a positive time delay
* in milliseconds to suggest deferring the backup pass for a while.
*
* @see [requestBackupTime]
*/
fun requestFullBackupTime(): Long = getBackupBackoff().apply {
Log.i(TAG, "Request full backup time. Returned $this")
}
fun checkFullBackupSize(size: Long) = full.checkFullBackupSize(size)
fun performFullBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor, flags: Int) =
full.performFullBackup(targetPackage, fileDescriptor, flags)
fun performFullBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor, flags: Int): Int {
val result = full.performFullBackup(targetPackage, fileDescriptor, flags)
if (result == TRANSPORT_OK) settingsManager.saveNewBackupTime()
return result
}
fun sendBackupData(numBytes: Int) = full.sendBackupData(numBytes)
@ -156,4 +199,16 @@ class BackupCoordinator(
metadataWriter.write(outputStream, token)
}
private fun getBackupBackoff(): Long {
val noBackoff = 0L
val defaultBackoff = DAYS.toMillis(30)
// back off if there's no storage set
val storage = settingsManager.getStorage() ?: return defaultBackoff
// don't back off if storage is not ejectable or available right now
return if (!storage.isUsb || storage.getDocumentFile(context).isDirectory) noBackoff
// otherwise back off
else defaultBackoff
}
}

View file

@ -36,11 +36,6 @@ class FullBackup(
fun hasState() = state != null
fun requestFullBackupTime(): Long {
Log.i(TAG, "Request full backup time")
return 0
}
fun getQuota(): Long = plugin.getQuota()
fun checkFullBackupSize(size: Long): Int {

View file

@ -27,11 +27,6 @@ class KVBackup(
fun hasState() = state != null
fun requestBackupTime(): Long {
Log.i(TAG, "Request K/V backup time")
return 0
}
fun getQuota(): Long = plugin.getQuota()
fun performBackup(packageInfo: PackageInfo, data: ParcelFileDescriptor, flags: Int): Int {

View file

@ -42,7 +42,7 @@ class DocumentsProviderBackupPlugin(
}
override val providerPackageName: String? by lazy {
val authority = storage.rootBackupDir?.uri?.authority ?: return@lazy null
val authority = storage.getAuthority() ?: return@lazy null
val providerInfo = packageManager.resolveContentProvider(authority, 0) ?: return@lazy null
providerInfo.packageName
}

View file

@ -4,8 +4,8 @@ import android.content.Context
import android.content.pm.PackageInfo
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.backup.settings.SettingsManager
import com.stevesoltys.backup.settings.Storage
import com.stevesoltys.backup.settings.getAndSaveNewBackupToken
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
@ -19,12 +19,13 @@ private const val MIME_TYPE = "application/octet-stream"
private val TAG = DocumentsStorage::class.java.simpleName
class DocumentsStorage(private val context: Context, storage: Storage?, token: Long) {
class DocumentsStorage(private val context: Context, private val settingsManager: SettingsManager) {
private val storage: Storage? = settingsManager.getStorage()
private val token: Long = settingsManager.getBackupToken()
internal val rootBackupDir: DocumentFile? by lazy {
val folderUri = storage?.uri ?: return@lazy null
// [fromTreeUri] should only return null when SDK_INT < 21
val parent = DocumentFile.fromTreeUri(context, folderUri) ?: throw AssertionError()
val parent = storage?.getDocumentFile(context) ?: return@lazy null
try {
val rootDir = parent.createOrGetDirectory(DIRECTORY_ROOT)
// create .nomedia file to prevent Android's MediaScanner from trying to index the backup
@ -38,7 +39,7 @@ class DocumentsStorage(private val context: Context, storage: Storage?, token: L
private val currentToken: Long by lazy {
if (token != 0L) token
else getAndSaveNewBackupToken(context).apply {
else settingsManager.getAndSaveNewBackupToken().apply {
Log.d(TAG, "Using a fresh backup token: $this")
}
}
@ -71,6 +72,8 @@ class DocumentsStorage(private val context: Context, storage: Storage?, token: L
}
}
fun getAuthority(): String? = storage?.uri?.authority
fun getSetDir(token: Long = currentToken): DocumentFile? {
if (token == currentToken) return currentSetDir
return rootBackupDir?.findFile(token.toString())

View file

@ -5,14 +5,13 @@ 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 com.stevesoltys.backup.settings.SettingsManager
import libcore.io.IoUtils.closeQuietly
import java.io.IOException
@ -23,7 +22,7 @@ private class RestoreCoordinatorState(
private val TAG = RestoreCoordinator::class.java.simpleName
internal class RestoreCoordinator(
private val context: Context,
private val settingsManager: SettingsManager,
private val plugin: RestorePlugin,
private val kv: KVRestore,
private val full: FullRestore,
@ -75,7 +74,7 @@ internal class RestoreCoordinator(
* or 0 if there is no backup set available corresponding to the current device state.
*/
fun getCurrentRestoreSet(): Long {
return getBackupToken(context)
return settingsManager.getBackupToken()
.apply { Log.i(TAG, "Got current restore set token: $this") }
}

View file

@ -1,18 +1,16 @@
package com.stevesoltys.backup.ui.storage
import android.app.ActivityManager
import android.app.Application
import android.app.backup.BackupProgress
import android.app.backup.IBackupObserver
import android.net.Uri
import android.os.UserHandle
import android.os.UserManager
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
import com.stevesoltys.backup.transport.requestBackup
private val TAG = BackupStorageViewModel::class.java.simpleName
@ -21,14 +19,19 @@ internal class BackupStorageViewModel(private val app: Application) : StorageVie
override val isRestoreOperation = false
override fun onLocationSet(uri: Uri) {
saveStorage(uri)
val isUsb = saveStorage(uri)
// use a new backup token
getAndSaveNewBackupToken(app)
settingsManager.getAndSaveNewBackupToken()
// initialize the new location
val observer = InitializationObserver()
Backup.backupManager.initializeTransportsForUser(UserHandle.myUserId(), arrayOf(TRANSPORT_ID), observer)
// if storage is on USB and this is not SetupWizard, do a backup right away
if (isUsb && !isSetupWizard) Thread {
requestBackup(app)
}.start()
}
@WorkerThread

View file

@ -31,6 +31,7 @@ class StorageActivity : BackupActivity() {
} else {
ViewModelProviders.of(this).get(BackupStorageViewModel::class.java)
}
viewModel.isSetupWizard = isSetupWizard()
viewModel.locationSet.observeEvent(this, LiveEventHandler {
showFragment(StorageCheckFragment.newInstance(getCheckFragmentTitle()), true)

View file

@ -33,7 +33,7 @@ data class StorageRoot(
internal val title: String,
internal val summary: String?,
internal val availableBytes: Long?,
internal val supportsEject: Boolean,
internal val isUsb: Boolean,
internal val enabled: Boolean = true) {
internal val uri: Uri by lazy {
@ -41,7 +41,7 @@ data class StorageRoot(
}
fun isInternal(): Boolean {
return authority == AUTHORITY_STORAGE && !supportsEject
return authority == AUTHORITY_STORAGE && !isUsb
}
}
@ -103,7 +103,7 @@ internal class StorageRootFetcher(private val context: Context) {
var cursor: Cursor? = null
try {
cursor = contentResolver.query(rootsUri, null, null, null, null)
while (cursor.moveToNext()) {
while (cursor!!.moveToNext()) {
val root = getStorageRoot(authority, cursor)
if (root != null) roots.add(root)
}
@ -122,7 +122,6 @@ internal class StorageRootFetcher(private val context: Context) {
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,
@ -131,13 +130,13 @@ internal class StorageRootFetcher(private val context: Context) {
title = cursor.getString(COLUMN_TITLE)!!,
summary = cursor.getString(COLUMN_SUMMARY),
availableBytes = cursor.getLong(COLUMN_AVAILABLE_BYTES),
supportsEject = supportsEject
isUsb = flags and FLAG_REMOVABLE_USB != 0
)
}
private fun checkOrAddUsbRoot(roots: ArrayList<StorageRoot>) {
for (root in roots) {
if (root.authority == AUTHORITY_STORAGE && root.supportsEject) return
if (root.authority == AUTHORITY_STORAGE && root.isUsb) return
}
val root = StorageRoot(
authority = AUTHORITY_STORAGE,
@ -147,7 +146,7 @@ internal class StorageRootFetcher(private val context: Context) {
title = context.getString(R.string.storage_fake_drive_title),
summary = context.getString(R.string.storage_fake_drive_summary),
availableBytes = null,
supportsEject = true,
isUsb = true,
enabled = false
)
roots.add(root)
@ -198,7 +197,7 @@ internal class StorageRootFetcher(private val context: Context) {
}
}
private fun getPackageIcon(context: Context, authority: String?, icon: Int): Drawable? {
private fun getPackageIcon(context: Context, authority: String, icon: Int): Drawable? {
if (icon != 0) {
val pm = context.packageManager
val info = pm.resolveContentProvider(authority, 0)

View file

@ -2,19 +2,22 @@ package com.stevesoltys.backup.ui.storage
import android.app.Application
import android.content.Context
import android.content.Context.USB_SERVICE
import android.content.Intent
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
import android.hardware.usb.UsbManager
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.Backup
import com.stevesoltys.backup.R
import com.stevesoltys.backup.isMassStorage
import com.stevesoltys.backup.settings.BackupManagerSettings
import com.stevesoltys.backup.settings.FlashDrive
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
@ -23,6 +26,8 @@ private val TAG = StorageViewModel::class.java.simpleName
internal abstract class StorageViewModel(private val app: Application) : AndroidViewModel(app), RemovableStorageListener {
protected val settingsManager = (app as Backup).settingsManager
private val mStorageRoots = MutableLiveData<List<StorageRoot>>()
internal val storageRoots: LiveData<List<StorageRoot>> get() = mStorageRoots
@ -35,14 +40,15 @@ internal abstract class StorageViewModel(private val app: Application) : Android
private val storageRootFetcher by lazy { StorageRootFetcher(app) }
private var storageRoot: StorageRoot? = null
internal var isSetupWizard: Boolean = false
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
val settingsManager = (context.applicationContext as Backup).settingsManager
val storage = settingsManager.getStorage() ?: return false
if (storage.isUsb) return true
return storage.getDocumentFile(context).isDirectory
}
}
@ -74,9 +80,12 @@ internal abstract class StorageViewModel(private val app: Application) : Android
onLocationSet(uri)
}
abstract fun onLocationSet(uri: Uri)
protected fun saveStorage(uri: Uri) {
/**
* Saves the storage behind the given [Uri] (and saved [storageRoot]).
*
* @return true if the storage is a USB flash drive, false otherwise.
*/
protected fun saveStorage(uri: Uri): Boolean {
// store backup storage location in settings
val root = storageRoot ?: throw IllegalStateException()
val name = if (root.isInternal()) {
@ -84,15 +93,47 @@ internal abstract class StorageViewModel(private val app: Application) : Android
} else {
root.title
}
val storage = Storage(uri, name, root.supportsEject)
setStorage(app, storage)
val storage = Storage(uri, name, root.isUsb)
settingsManager.setStorage(storage)
// reset time of last backup to "Never"
settingsManager.resetBackupTime()
if (storage.isUsb) {
Log.d(TAG, "Selected storage is a removable USB device.")
val wasSaved = saveUsbDevice()
// reset stored flash drive, if we did not update it
if (!wasSaved) settingsManager.setFlashDrive(null)
BackupManagerSettings.disableAutomaticBackups(app.contentResolver)
} else {
settingsManager.setFlashDrive(null)
BackupManagerSettings.enableAutomaticBackups(app.contentResolver)
}
// 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")
return storage.isUsb
}
private fun saveUsbDevice(): Boolean {
val manager = app.getSystemService(USB_SERVICE) as UsbManager
manager.deviceList.values.forEach { device ->
if (device.isMassStorage()) {
val flashDrive = FlashDrive.from(device)
settingsManager.setFlashDrive(flashDrive)
Log.d(TAG, "Saved flash drive: $flashDrive")
return true
}
}
Log.e(TAG, "No USB device found even though we were expecting one.")
return false
}
abstract fun onLocationSet(uri: Uri)
override fun onCleared() {
storageRootFetcher.setRemovableStorageListener(null)
super.onCleared()

View file

@ -5,14 +5,16 @@
<item
android:id="@+id/action_backup"
android:enabled="false"
android:title="@string/settings_backup_now"
app:showAsAction="never" />
<item
android:id="@+id/action_restore"
android:enabled="false"
android:title="@string/restore_backup_button"
android:visible="false"
app:showAsAction="never"
tools:visible="true" />
</menu>
</menu>

View file

@ -31,6 +31,8 @@
<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_backup_last_backup_never">Never</string>
<string name="settings_backup_location_summary">%s · Last Backup %s</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>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<usb-device
class="8"
protocol="80"
subclass="6" />
</resources>

View file

@ -43,14 +43,14 @@ 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(context, backupPlugin, kvBackup, fullBackup, metadataWriter, notificationManager)
private val backup = BackupCoordinator(context, backupPlugin, kvBackup, fullBackup, metadataWriter, settingsManager, 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(context, restorePlugin, kvRestore, fullRestore, metadataReader)
private val restore = RestoreCoordinator(settingsManager, restorePlugin, kvRestore, fullRestore, metadataReader)
private val backupDataInput = mockk<BackupDataInput>()
private val fileDescriptor = mockk<ParcelFileDescriptor>(relaxed = true)
@ -91,6 +91,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
appData2.size
}
every { kvBackupPlugin.getOutputStreamForRecord(packageInfo, key264) } returns bOutputStream2
every { settingsManager.saveNewBackupTime() } just Runs
// start and finish K/V backup
assertEquals(TRANSPORT_OK, backup.performIncrementalBackup(packageInfo, fileDescriptor, 0))
@ -130,6 +131,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
every { fullBackupPlugin.getOutputStream(packageInfo) } returns bOutputStream
every { inputFactory.getInputStream(fileDescriptor) } returns bInputStream
every { fullBackupPlugin.getQuota() } returns DEFAULT_QUOTA_FULL_BACKUP
every { settingsManager.saveNewBackupTime() } just Runs
// perform backup to output stream
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, fileDescriptor, 0))

View file

@ -4,6 +4,7 @@ import android.content.Context
import android.content.pm.PackageInfo
import android.util.Log
import com.stevesoltys.backup.crypto.Crypto
import com.stevesoltys.backup.settings.SettingsManager
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
@ -14,6 +15,7 @@ import org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD
abstract class TransportTest {
protected val crypto = mockk<Crypto>()
protected val settingsManager = mockk<SettingsManager>()
protected val context = mockk<Context>(relaxed = true)
protected val packageInfo = PackageInfo().apply { packageName = "org.example" }

View file

@ -2,8 +2,12 @@ package com.stevesoltys.backup.transport.backup
import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_OK
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.backup.BackupNotificationManager
import com.stevesoltys.backup.getRandomString
import com.stevesoltys.backup.metadata.MetadataWriter
import com.stevesoltys.backup.settings.Storage
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
@ -23,14 +27,15 @@ internal class BackupCoordinatorTest: BackupTest() {
private val metadataWriter = mockk<MetadataWriter>()
private val notificationManager = mockk<BackupNotificationManager>()
private val backup = BackupCoordinator(context, plugin, kv, full, metadataWriter, notificationManager)
private val backup = BackupCoordinator(context, plugin, kv, full, metadataWriter, settingsManager, notificationManager)
private val metadataOutputStream = mockk<OutputStream>()
@Test
fun `device initialization succeeds and delegates to plugin`() {
every { plugin.initializeDevice() } just Runs
expectWritingMetadata(0L)
every { settingsManager.getBackupToken() } returns token
expectWritingMetadata(token)
every { kv.hasState() } returns false
every { full.hasState() } returns false
@ -39,8 +44,11 @@ internal class BackupCoordinatorTest: BackupTest() {
}
@Test
fun `device initialization fails`() {
fun `error notification when device initialization fails`() {
val storage = Storage(Uri.EMPTY, getRandomString(), false)
every { plugin.initializeDevice() } throws IOException()
every { settingsManager.getStorage() } returns storage
every { notificationManager.onBackupError() } just Runs
assertEquals(TRANSPORT_ERROR, backup.initializeDevice())
@ -53,6 +61,27 @@ internal class BackupCoordinatorTest: BackupTest() {
}
}
@Test
fun `no error notification when device initialization fails on unplugged USB storage`() {
val storage = mockk<Storage>()
val documentFile = mockk<DocumentFile>()
every { plugin.initializeDevice() } throws IOException()
every { settingsManager.getStorage() } returns storage
every { storage.isUsb } returns true
every { storage.getDocumentFile(context) } returns documentFile
every { documentFile.isDirectory } returns false
assertEquals(TRANSPORT_ERROR, backup.initializeDevice())
// finish will only be called when TRANSPORT_OK is returned, so it should throw
every { kv.hasState() } returns false
every { full.hasState() } returns false
assertThrows(IllegalStateException::class.java) {
backup.finishBackup()
}
}
@Test
fun `getBackupQuota() delegates to right plugin`() {
val isFullBackup = Random.nextBoolean()

View file

@ -20,11 +20,6 @@ internal class FullBackupTest : BackupTest() {
private val closeBytes = ByteArray(42).apply { Random.nextBytes(this) }
private val inputStream = mockk<FileInputStream>()
@Test
fun `now is a good time for a backup`() {
assertEquals(0, backup.requestFullBackupTime())
}
@Test
fun `has no initial state`() {
assertFalse(backup.hasState())

View file

@ -28,11 +28,6 @@ internal class KVBackupTest : BackupTest() {
private val value = ByteArray(23).apply { Random.nextBytes(this) }
private val versionHeader = VersionHeader(packageName = packageInfo.packageName, key = key)
@Test
fun `now is a good time for a backup`() {
assertEquals(0, backup.requestBackupTime())
}
@Test
fun `has no initial state`() {
assertFalse(backup.hasState())

View file

@ -27,7 +27,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
private val full = mockk<FullRestore>()
private val metadataReader = mockk<MetadataReader>()
private val restore = RestoreCoordinator(context, plugin, kv, full, metadataReader)
private val restore = RestoreCoordinator(settingsManager, plugin, kv, full, metadataReader)
private val token = Random.nextLong()
private val inputStream = mockk<InputStream>()
@ -56,8 +56,8 @@ internal class RestoreCoordinatorTest : TransportTest() {
@Test
fun `getCurrentRestoreSet() delegates to plugin`() {
// We don't mock the SettingsManager, so the default value is returned here
assertEquals(0L, restore.getCurrentRestoreSet())
every { settingsManager.getBackupToken() } returns token
assertEquals(token, restore.getCurrentRestoreSet())
}
@Test

View file

@ -2,5 +2,7 @@
<permissions>
<privapp-permissions package="com.stevesoltys.backup">
<permission name="android.permission.BACKUP"/>
<permission name="android.permission.MANAGE_USB"/>
<permission name="android.permission.WRITE_SECURE_SETTINGS"/>
</privapp-permissions>
</permissions>