diff --git a/app/src/androidTest/java/com/stevesoltys/backup/DocumentsStorageTest.kt b/app/src/androidTest/java/com/stevesoltys/backup/DocumentsStorageTest.kt
index 43786866..6d8034e1 100644
--- a/app/src/androidTest/java/com/stevesoltys/backup/DocumentsStorageTest.kt
+++ b/app/src/androidTest/java/com/stevesoltys/backup/DocumentsStorageTest.kt
@@ -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
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a7b500cf..14914e4d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -14,6 +14,16 @@
android:name="android.permission.MANAGE_DOCUMENTS"
tools:ignore="ProtectedPermissions" />
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/stevesoltys/backup/Backup.kt b/app/src/main/java/com/stevesoltys/backup/Backup.kt
index 787b9a6c..1494c9c8 100644
--- a/app/src/main/java/com/stevesoltys/backup/Backup.kt
+++ b/app/src/main/java/com/stevesoltys/backup/Backup.kt
@@ -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"
diff --git a/app/src/main/java/com/stevesoltys/backup/BackupNotificationManager.kt b/app/src/main/java/com/stevesoltys/backup/BackupNotificationManager.kt
index 51b9317e..d8df735a 100644
--- a/app/src/main/java/com/stevesoltys/backup/BackupNotificationManager.kt
+++ b/app/src/main/java/com/stevesoltys/backup/BackupNotificationManager.kt
@@ -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)
}
diff --git a/app/src/main/java/com/stevesoltys/backup/NotificationBackupObserver.kt b/app/src/main/java/com/stevesoltys/backup/NotificationBackupObserver.kt
index 4486d627..2d264023 100644
--- a/app/src/main/java/com/stevesoltys/backup/NotificationBackupObserver.kt
+++ b/app/src/main/java/com/stevesoltys/backup/NotificationBackupObserver.kt
@@ -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)
}
diff --git a/app/src/main/java/com/stevesoltys/backup/UsbIntentReceiver.kt b/app/src/main/java/com/stevesoltys/backup/UsbIntentReceiver.kt
new file mode 100644
index 00000000..b4daca68
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/UsbIntentReceiver.kt
@@ -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(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()}")
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/activity/MainActivityController.java b/app/src/main/java/com/stevesoltys/backup/activity/MainActivityController.java
index edb5f9ab..0efa9829 100644
--- a/app/src/main/java/com/stevesoltys/backup/activity/MainActivityController.java
+++ b/app/src/main/java/com/stevesoltys/backup/activity/MainActivityController.java
@@ -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) {
diff --git a/app/src/main/java/com/stevesoltys/backup/activity/backup/CreateBackupActivityController.java b/app/src/main/java/com/stevesoltys/backup/activity/backup/CreateBackupActivityController.java
index f83d1a8c..46e3c6ea 100644
--- a/app/src/main/java/com/stevesoltys/backup/activity/backup/CreateBackupActivityController.java
+++ b/app/src/main/java/com/stevesoltys/backup/activity/backup/CreateBackupActivityController.java
@@ -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 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 selectedPackages, Activity parent) {
diff --git a/app/src/main/java/com/stevesoltys/backup/restore/RestoreProgressFragment.kt b/app/src/main/java/com/stevesoltys/backup/restore/RestoreProgressFragment.kt
index 679c953a..28d35ce4 100644
--- a/app/src/main/java/com/stevesoltys/backup/restore/RestoreProgressFragment.kt
+++ b/app/src/main/java/com/stevesoltys/backup/restore/RestoreProgressFragment.kt
@@ -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)
diff --git a/app/src/main/java/com/stevesoltys/backup/service/PackageService.kt b/app/src/main/java/com/stevesoltys/backup/service/PackageService.kt
index 0ec729e6..4154933e 100644
--- a/app/src/main/java/com/stevesoltys/backup/service/PackageService.kt
+++ b/app/src/main/java/com/stevesoltys/backup/service/PackageService.kt
@@ -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()
}
diff --git a/app/src/main/java/com/stevesoltys/backup/settings/BackupManagerSettings.kt b/app/src/main/java/com/stevesoltys/backup/settings/BackupManagerSettings.kt
new file mode 100644
index 00000000..e89f7e0a
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/backup/settings/BackupManagerSettings.kt
@@ -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)
+ }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/backup/settings/SettingsFragment.kt b/app/src/main/java/com/stevesoltys/backup/settings/SettingsFragment.kt
index ba8de843..af525c52 100644
--- a/app/src/main/java/com/stevesoltys/backup/settings/SettingsFragment.kt
+++ b/app/src/main/java/com/stevesoltys/backup/settings/SettingsFragment.kt
@@ -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("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
+ }
+ }
+
}
diff --git a/app/src/main/java/com/stevesoltys/backup/settings/SettingsManager.kt b/app/src/main/java/com/stevesoltys/backup/settings/SettingsManager.kt
index 70ec8db5..eadf7d28 100644
--- a/app/src/main/java/com/stevesoltys/backup/settings/SettingsManager.kt
+++ b/app/src/main/java/com/stevesoltys/backup/settings/SettingsManager.kt
@@ -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
+ )
+ }
}
diff --git a/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt b/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt
index 3959aa9f..465ae946 100644
--- a/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt
+++ b/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt
@@ -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()
}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.kt b/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.kt
index 7b6e564c..da9f211b 100644
--- a/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.kt
+++ b/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.kt
@@ -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 {
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransportService.kt b/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransportService.kt
index c2b54127..40ac8376 100644
--- a/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransportService.kt
+++ b/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransportService.kt
@@ -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
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt b/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt
index b896594c..9056cb6e 100644
--- a/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt
+++ b/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt
@@ -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)
}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupCoordinator.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupCoordinator.kt
index c244e611..d693caee 100644
--- a/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupCoordinator.kt
+++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupCoordinator.kt
@@ -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
+ }
+
}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/FullBackup.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/FullBackup.kt
index ec2746d5..37639176 100644
--- a/app/src/main/java/com/stevesoltys/backup/transport/backup/FullBackup.kt
+++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/FullBackup.kt
@@ -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 {
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/KVBackup.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/KVBackup.kt
index 05138e0a..7e3de67a 100644
--- a/app/src/main/java/com/stevesoltys/backup/transport/backup/KVBackup.kt
+++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/KVBackup.kt
@@ -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 {
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderBackupPlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderBackupPlugin.kt
index 3c378b7e..49033ac7 100644
--- a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderBackupPlugin.kt
+++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderBackupPlugin.kt
@@ -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
}
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsStorage.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsStorage.kt
index 505dd86c..e4a80f1a 100644
--- a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsStorage.kt
+++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsStorage.kt
@@ -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())
diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/RestoreCoordinator.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/RestoreCoordinator.kt
index aa6334fb..4cca2cdd 100644
--- a/app/src/main/java/com/stevesoltys/backup/transport/restore/RestoreCoordinator.kt
+++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/RestoreCoordinator.kt
@@ -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") }
}
diff --git a/app/src/main/java/com/stevesoltys/backup/ui/storage/BackupStorageViewModel.kt b/app/src/main/java/com/stevesoltys/backup/ui/storage/BackupStorageViewModel.kt
index 103fc5c3..9f771ca1 100644
--- a/app/src/main/java/com/stevesoltys/backup/ui/storage/BackupStorageViewModel.kt
+++ b/app/src/main/java/com/stevesoltys/backup/ui/storage/BackupStorageViewModel.kt
@@ -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
diff --git a/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageActivity.kt b/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageActivity.kt
index e4e50af0..b5981491 100644
--- a/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageActivity.kt
+++ b/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageActivity.kt
@@ -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)
diff --git a/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageRootFetcher.kt b/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageRootFetcher.kt
index 73d63f87..78792c56 100644
--- a/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageRootFetcher.kt
+++ b/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageRootFetcher.kt
@@ -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) {
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)
diff --git a/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageViewModel.kt b/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageViewModel.kt
index 1868f25b..fc6ad663 100644
--- a/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageViewModel.kt
+++ b/app/src/main/java/com/stevesoltys/backup/ui/storage/StorageViewModel.kt
@@ -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>()
internal val storageRoots: LiveData> 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()
diff --git a/app/src/main/res/menu/settings_menu.xml b/app/src/main/res/menu/settings_menu.xml
index 7a055c11..71b7d5fc 100644
--- a/app/src/main/res/menu/settings_menu.xml
+++ b/app/src/main/res/menu/settings_menu.xml
@@ -5,14 +5,16 @@
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 26a21938..d631845a 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -31,6 +31,8 @@
The chosen location can not be used.
None
Internal Storage
+ Never
+ %s ยท Last Backup %s
All backups are encrypted on your phone. To restore from backup you will need your 12-word recovery code.
Automatic restore
When reinstalling an app, restore backed up settings and data
diff --git a/app/src/main/res/xml/device_filter.xml b/app/src/main/res/xml/device_filter.xml
new file mode 100644
index 00000000..505d83b7
--- /dev/null
+++ b/app/src/main/res/xml/device_filter.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt
index 3d4a48c4..449299b0 100644
--- a/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt
+++ b/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt
@@ -43,14 +43,14 @@ internal class CoordinatorIntegrationTest : TransportTest() {
private val fullBackupPlugin = mockk()
private val fullBackup = FullBackup(fullBackupPlugin, inputFactory, headerWriter, cryptoImpl)
private val notificationManager = mockk()
- 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()
private val kvRestorePlugin = mockk()
private val kvRestore = KVRestore(kvRestorePlugin, outputFactory, headerReader, cryptoImpl)
private val fullRestorePlugin = mockk()
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()
private val fileDescriptor = mockk(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))
diff --git a/app/src/test/java/com/stevesoltys/backup/transport/TransportTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/TransportTest.kt
index 0241db5d..8ec84cef 100644
--- a/app/src/test/java/com/stevesoltys/backup/transport/TransportTest.kt
+++ b/app/src/test/java/com/stevesoltys/backup/transport/TransportTest.kt
@@ -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()
+ protected val settingsManager = mockk()
protected val context = mockk(relaxed = true)
protected val packageInfo = PackageInfo().apply { packageName = "org.example" }
diff --git a/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupCoordinatorTest.kt
index 97ac1bef..0af97cb6 100644
--- a/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupCoordinatorTest.kt
+++ b/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupCoordinatorTest.kt
@@ -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()
private val notificationManager = mockk()
- 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()
@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()
+ val documentFile = mockk()
+
+ 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()
diff --git a/app/src/test/java/com/stevesoltys/backup/transport/backup/FullBackupTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/backup/FullBackupTest.kt
index af66c82b..b8b79cf7 100644
--- a/app/src/test/java/com/stevesoltys/backup/transport/backup/FullBackupTest.kt
+++ b/app/src/test/java/com/stevesoltys/backup/transport/backup/FullBackupTest.kt
@@ -20,11 +20,6 @@ internal class FullBackupTest : BackupTest() {
private val closeBytes = ByteArray(42).apply { Random.nextBytes(this) }
private val inputStream = mockk()
- @Test
- fun `now is a good time for a backup`() {
- assertEquals(0, backup.requestFullBackupTime())
- }
-
@Test
fun `has no initial state`() {
assertFalse(backup.hasState())
diff --git a/app/src/test/java/com/stevesoltys/backup/transport/backup/KVBackupTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/backup/KVBackupTest.kt
index 422c3aab..59955663 100644
--- a/app/src/test/java/com/stevesoltys/backup/transport/backup/KVBackupTest.kt
+++ b/app/src/test/java/com/stevesoltys/backup/transport/backup/KVBackupTest.kt
@@ -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())
diff --git a/app/src/test/java/com/stevesoltys/backup/transport/restore/RestoreCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/restore/RestoreCoordinatorTest.kt
index afd4df71..2c504cdb 100644
--- a/app/src/test/java/com/stevesoltys/backup/transport/restore/RestoreCoordinatorTest.kt
+++ b/app/src/test/java/com/stevesoltys/backup/transport/restore/RestoreCoordinatorTest.kt
@@ -27,7 +27,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
private val full = mockk()
private val metadataReader = mockk()
- 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()
@@ -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
diff --git a/permissions_com.stevesoltys.backup.xml b/permissions_com.stevesoltys.backup.xml
index a5ee7318..f0195387 100644
--- a/permissions_com.stevesoltys.backup.xml
+++ b/permissions_com.stevesoltys.backup.xml
@@ -2,5 +2,7 @@
+
+
\ No newline at end of file