Start backup automatically when flash drive used for backup is plugged in
This commit is contained in:
parent
650642068e
commit
b0386c8b66
9 changed files with 183 additions and 20 deletions
|
@ -72,5 +72,16 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".UsbIntentReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
|
||||||
|
android:resource="@xml/device_filter" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
@ -0,0 +1,78 @@
|
||||||
|
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.ACTION_USB_DEVICE_ATTACHED
|
||||||
|
import android.hardware.usb.UsbManager.EXTRA_DEVICE
|
||||||
|
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.settings.getFlashDrive
|
||||||
|
import com.stevesoltys.backup.transport.requestBackup
|
||||||
|
import com.stevesoltys.backup.ui.storage.AUTHORITY_STORAGE
|
||||||
|
|
||||||
|
private val TAG = UsbIntentReceiver::class.java.simpleName
|
||||||
|
|
||||||
|
class UsbIntentReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (intent.action != ACTION_USB_DEVICE_ATTACHED) return
|
||||||
|
|
||||||
|
val device = intent.extras?.getParcelable<UsbDevice>(EXTRA_DEVICE) ?: return
|
||||||
|
Log.d(TAG, "New USB mass-storage device attached.")
|
||||||
|
device.log()
|
||||||
|
|
||||||
|
val savedFlashDrive = getFlashDrive(context) ?: return
|
||||||
|
val attachedFlashDrive = FlashDrive.from(device)
|
||||||
|
if (savedFlashDrive == attachedFlashDrive) {
|
||||||
|
Log.d(TAG, "Matches stored device, requesting backup...")
|
||||||
|
// TODO only if last backup older than 24h
|
||||||
|
startBackupOnceMounted(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
private fun startBackupOnceMounted(context: Context) {
|
||||||
|
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)
|
||||||
|
Thread {
|
||||||
|
requestBackup(context)
|
||||||
|
}.start()
|
||||||
|
contentResolver.unregisterContentObserver(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contentResolver.registerContentObserver(rootsUri, true, observer)
|
||||||
|
}
|
||||||
|
|
||||||
|
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") // requires a permission since API 29
|
||||||
|
Log.d(TAG, " productId: $productId")
|
||||||
|
Log.d(TAG, " vendorId: $vendorId")
|
||||||
|
Log.d(TAG, " isMassStorage: ${isMassStorage()}")
|
||||||
|
}
|
|
@ -1,14 +1,21 @@
|
||||||
package com.stevesoltys.backup.settings
|
package com.stevesoltys.backup.settings
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.hardware.usb.UsbDevice
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.preference.PreferenceManager.getDefaultSharedPreferences
|
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
private const val PREF_KEY_STORAGE_URI = "storageUri"
|
private const val PREF_KEY_STORAGE_URI = "storageUri"
|
||||||
private const val PREF_KEY_STORAGE_NAME = "storageName"
|
private const val PREF_KEY_STORAGE_NAME = "storageName"
|
||||||
private const val PREF_KEY_STORAGE_EJECTABLE = "storageEjectable"
|
private const val PREF_KEY_STORAGE_EJECTABLE = "storageEjectable"
|
||||||
|
|
||||||
|
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_TOKEN = "backupToken"
|
||||||
private const val PREF_KEY_BACKUP_PASSWORD = "backupLegacyPassword"
|
private const val PREF_KEY_BACKUP_PASSWORD = "backupLegacyPassword"
|
||||||
|
|
||||||
|
@ -21,7 +28,7 @@ data class Storage(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setStorage(context: Context, storage: Storage) {
|
fun setStorage(context: Context, storage: Storage) {
|
||||||
getDefaultSharedPreferences(context)
|
PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
.edit()
|
.edit()
|
||||||
.putString(PREF_KEY_STORAGE_URI, storage.uri.toString())
|
.putString(PREF_KEY_STORAGE_URI, storage.uri.toString())
|
||||||
.putString(PREF_KEY_STORAGE_NAME, storage.name)
|
.putString(PREF_KEY_STORAGE_NAME, storage.name)
|
||||||
|
@ -30,7 +37,7 @@ fun setStorage(context: Context, storage: Storage) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getStorage(context: Context): Storage? {
|
fun getStorage(context: Context): Storage? {
|
||||||
val prefs = getDefaultSharedPreferences(context)
|
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
val uriStr = prefs.getString(PREF_KEY_STORAGE_URI, null) ?: return null
|
val uriStr = prefs.getString(PREF_KEY_STORAGE_URI, null) ?: return null
|
||||||
val uri = Uri.parse(uriStr)
|
val uri = Uri.parse(uriStr)
|
||||||
val name = prefs.getString(PREF_KEY_STORAGE_NAME, null) ?: throw IllegalStateException()
|
val name = prefs.getString(PREF_KEY_STORAGE_NAME, null) ?: throw IllegalStateException()
|
||||||
|
@ -38,12 +45,56 @@ fun getStorage(context: Context): Storage? {
|
||||||
return Storage(uri, name, ejectable)
|
return Storage(uri, name, ejectable)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 requires a permission since API 29
|
||||||
|
vendorId = device.vendorId,
|
||||||
|
productId = device.productId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setFlashDrive(context: Context, usb: FlashDrive?) {
|
||||||
|
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
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(context: Context): FlashDrive? {
|
||||||
|
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
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.
|
* Generates and returns a new backup token while saving it as well.
|
||||||
* Subsequent calls to [getBackupToken] will return this new token once saved.
|
* Subsequent calls to [getBackupToken] will return this new token once saved.
|
||||||
*/
|
*/
|
||||||
fun getAndSaveNewBackupToken(context: Context): Long = Date().time.apply {
|
fun getAndSaveNewBackupToken(context: Context): Long = Date().time.apply {
|
||||||
getDefaultSharedPreferences(context)
|
PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
.edit()
|
.edit()
|
||||||
.putLong(PREF_KEY_BACKUP_TOKEN, this)
|
.putLong(PREF_KEY_BACKUP_TOKEN, this)
|
||||||
.apply()
|
.apply()
|
||||||
|
@ -53,10 +104,10 @@ fun getAndSaveNewBackupToken(context: Context): Long = Date().time.apply {
|
||||||
* Returns the current backup token or 0 if none exists.
|
* Returns the current backup token or 0 if none exists.
|
||||||
*/
|
*/
|
||||||
fun getBackupToken(context: Context): Long {
|
fun getBackupToken(context: Context): Long {
|
||||||
return getDefaultSharedPreferences(context).getLong(PREF_KEY_BACKUP_TOKEN, 0L)
|
return PreferenceManager.getDefaultSharedPreferences(context).getLong(PREF_KEY_BACKUP_TOKEN, 0L)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Deprecated("Replaced by KeyManager#getBackupKey()")
|
@Deprecated("Replaced by KeyManager#getBackupKey()")
|
||||||
fun getBackupPassword(context: Context): String? {
|
fun getBackupPassword(context: Context): String? {
|
||||||
return getDefaultSharedPreferences(context).getString(PREF_KEY_BACKUP_PASSWORD, null)
|
return PreferenceManager.getDefaultSharedPreferences(context).getString(PREF_KEY_BACKUP_PASSWORD, null)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,20 +1,14 @@
|
||||||
package com.stevesoltys.backup.settings
|
package com.stevesoltys.backup.settings
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import com.stevesoltys.backup.Backup
|
|
||||||
import com.stevesoltys.backup.R
|
|
||||||
import com.stevesoltys.backup.transport.requestBackup
|
import com.stevesoltys.backup.transport.requestBackup
|
||||||
import com.stevesoltys.backup.ui.RequireProvisioningViewModel
|
import com.stevesoltys.backup.ui.RequireProvisioningViewModel
|
||||||
|
|
||||||
private val TAG = SettingsViewModel::class.java.simpleName
|
|
||||||
|
|
||||||
class SettingsViewModel(app: Application) : RequireProvisioningViewModel(app) {
|
class SettingsViewModel(app: Application) : RequireProvisioningViewModel(app) {
|
||||||
|
|
||||||
override val isRestoreOperation = false
|
override val isRestoreOperation = false
|
||||||
|
|
||||||
fun backupNow() {
|
fun backupNow() {
|
||||||
val nm = (app as Backup).notificationManager
|
|
||||||
nm.onBackupUpdate(app.getString(R.string.notification_backup_starting), 0, 1, true)
|
|
||||||
Thread { requestBackup(app) }.start()
|
Thread { requestBackup(app) }.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,6 @@ import android.app.backup.RestoreSet
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
import android.os.Build.VERSION.SDK_INT
|
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.stevesoltys.backup.settings.SettingsActivity
|
import com.stevesoltys.backup.settings.SettingsActivity
|
||||||
|
@ -36,7 +35,7 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getTransportFlags(): Int {
|
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 {
|
override fun dataManagementIntent(): Intent {
|
||||||
|
|
|
@ -14,6 +14,7 @@ import android.util.Log
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
import com.stevesoltys.backup.Backup
|
import com.stevesoltys.backup.Backup
|
||||||
import com.stevesoltys.backup.NotificationBackupObserver
|
import com.stevesoltys.backup.NotificationBackupObserver
|
||||||
|
import com.stevesoltys.backup.R
|
||||||
import com.stevesoltys.backup.service.PackageService
|
import com.stevesoltys.backup.service.PackageService
|
||||||
import com.stevesoltys.backup.session.backup.BackupMonitor
|
import com.stevesoltys.backup.session.backup.BackupMonitor
|
||||||
|
|
||||||
|
@ -50,7 +51,10 @@ class ConfigurableBackupTransportService : Service() {
|
||||||
|
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
fun requestBackup(context: Context) {
|
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 observer = NotificationBackupObserver(context, true)
|
||||||
val flags = FLAG_NON_INCREMENTAL_BACKUP or FLAG_USER_INITIATED
|
val flags = FLAG_NON_INCREMENTAL_BACKUP or FLAG_USER_INITIATED
|
||||||
val packages = PackageService().eligiblePackages
|
val packages = PackageService().eligiblePackages
|
||||||
|
|
|
@ -103,7 +103,7 @@ internal class StorageRootFetcher(private val context: Context) {
|
||||||
var cursor: Cursor? = null
|
var cursor: Cursor? = null
|
||||||
try {
|
try {
|
||||||
cursor = contentResolver.query(rootsUri, null, null, null, null)
|
cursor = contentResolver.query(rootsUri, null, null, null, null)
|
||||||
while (cursor.moveToNext()) {
|
while (cursor!!.moveToNext()) {
|
||||||
val root = getStorageRoot(authority, cursor)
|
val root = getStorageRoot(authority, cursor)
|
||||||
if (root != null) roots.add(root)
|
if (root != null) roots.add(root)
|
||||||
}
|
}
|
||||||
|
@ -198,7 +198,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) {
|
if (icon != 0) {
|
||||||
val pm = context.packageManager
|
val pm = context.packageManager
|
||||||
val info = pm.resolveContentProvider(authority, 0)
|
val info = pm.resolveContentProvider(authority, 0)
|
||||||
|
|
|
@ -2,18 +2,19 @@ package com.stevesoltys.backup.ui.storage
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Context.USB_SERVICE
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
|
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
|
import android.hardware.usb.UsbManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import com.stevesoltys.backup.R
|
import com.stevesoltys.backup.R
|
||||||
import com.stevesoltys.backup.settings.Storage
|
import com.stevesoltys.backup.isMassStorage
|
||||||
import com.stevesoltys.backup.settings.getStorage
|
import com.stevesoltys.backup.settings.*
|
||||||
import com.stevesoltys.backup.settings.setStorage
|
|
||||||
import com.stevesoltys.backup.transport.ConfigurableBackupTransportService
|
import com.stevesoltys.backup.transport.ConfigurableBackupTransportService
|
||||||
import com.stevesoltys.backup.ui.LiveEvent
|
import com.stevesoltys.backup.ui.LiveEvent
|
||||||
import com.stevesoltys.backup.ui.MutableLiveEvent
|
import com.stevesoltys.backup.ui.MutableLiveEvent
|
||||||
|
@ -85,12 +86,30 @@ internal abstract class StorageViewModel(private val app: Application) : Android
|
||||||
val storage = Storage(uri, name, root.supportsEject)
|
val storage = Storage(uri, name, root.supportsEject)
|
||||||
setStorage(app, storage)
|
setStorage(app, storage)
|
||||||
|
|
||||||
|
if (storage.ejectable) {
|
||||||
|
val wasSaved = saveUsbDevice()
|
||||||
|
// reset stored flash drive, if we did not update it
|
||||||
|
if (!wasSaved) setFlashDrive(app, null)
|
||||||
|
}
|
||||||
|
|
||||||
// stop backup service to be sure the old location will get updated
|
// stop backup service to be sure the old location will get updated
|
||||||
app.stopService(Intent(app, ConfigurableBackupTransportService::class.java))
|
app.stopService(Intent(app, ConfigurableBackupTransportService::class.java))
|
||||||
|
|
||||||
Log.d(TAG, "New storage location saved: $uri")
|
Log.d(TAG, "New storage location saved: $uri")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun saveUsbDevice(): Boolean {
|
||||||
|
val manager = app.getSystemService(USB_SERVICE) as UsbManager
|
||||||
|
manager.deviceList.values.forEach { device ->
|
||||||
|
if (device.isMassStorage()) {
|
||||||
|
setFlashDrive(app, FlashDrive.from(device))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log.w(TAG, "No USB device found for ejectable storage.")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
storageRootFetcher.setRemovableStorageListener(null)
|
storageRootFetcher.setRemovableStorageListener(null)
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
|
|
7
app/src/main/res/xml/device_filter.xml
Normal file
7
app/src/main/res/xml/device_filter.xml
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<usb-device
|
||||||
|
class="8"
|
||||||
|
protocol="80"
|
||||||
|
subclass="6" />
|
||||||
|
</resources>
|
Loading…
Reference in a new issue