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