Skip to content

Commit

Permalink
Use UsbMonitorService in foreground to wait for USB root
Browse files Browse the repository at this point in the history
See: https://developer.android.com/develop/background-work/background-tasks/broadcasts#android-14
for why we don't get broadcasts while the system has us in cached state
  • Loading branch information
grote committed Jan 7, 2025
1 parent c2a678f commit f892b8a
Show file tree
Hide file tree
Showing 7 changed files with 138 additions and 55 deletions.
6 changes: 6 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,12 @@
android:exported="false"
android:foregroundServiceType="dataSync"
android:label="RestoreService" />
<!-- Does wait for USB storage root to appear after device was plugged in -->
<service
android:name=".UsbMonitorService"
android:exported="false"
android:foregroundServiceType="shortService"
android:label="UsbMonitorService" />

</application>
</manifest>
84 changes: 30 additions & 54 deletions app/src/main/java/com/stevesoltys/seedvault/UsbIntentReceiver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,43 +5,61 @@

package com.stevesoltys.seedvault

import android.app.backup.IBackupManager
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.ACTION_USB_DEVICE_DETACHED
import android.hardware.usb.UsbManager.EXTRA_DEVICE
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.provider.DocumentsContract
import android.util.Log
import com.stevesoltys.seedvault.settings.FlashDrive
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_STORAGE
import com.stevesoltys.seedvault.worker.BackupRequester.Companion.requestFilesAndAppBackup
import org.koin.core.context.GlobalContext.get
import java.util.Date

private val TAG = UsbIntentReceiver::class.java.simpleName

class UsbIntentReceiver : UsbMonitor() {
/**
* When we get the [ACTION_USB_DEVICE_ATTACHED] broadcast, the storage is not yet available.
* So we need to use a ContentObserver inside [UsbMonitorService]
* to request a backup only once available.
* We can't use the ContentObserver here, because if we are not in foreground,
* the system freezes/caches us and queues our broadcasts until we are in foreground again.
*/
class UsbIntentReceiver : BroadcastReceiver() {

// using KoinComponent would crash robolectric tests :(
private val settingsManager: SettingsManager by lazy { get().get() }
private val backupManager: IBackupManager by lazy { get().get() }

override fun shouldMonitorStatus(context: Context, action: String, device: UsbDevice): Boolean {
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, UsbDevice::class.java) ?: return
Log.d(TAG, "New USB mass-storage device attached.")
device.log()

if (needsBackup(action, device)) {
val i = Intent(context, UsbMonitorService::class.java).apply {
data = DocumentsContract.buildRootsUri(AUTHORITY_STORAGE)
}
context.startForegroundService(i)
}
}
}

private fun needsBackup(action: String, device: UsbDevice): Boolean {
if (action != ACTION_USB_DEVICE_ATTACHED) return false
Log.d(TAG, "Checking if this is the current backup drive.")
Log.d(TAG, "Checking if this is the current backup drive...")
val savedFlashDrive = settingsManager.getFlashDrive() ?: return false
val attachedFlashDrive = FlashDrive.from(device)
return if (savedFlashDrive == attachedFlashDrive) {
Log.d(TAG, "Matches stored device, checking backup time...")
Log.d(TAG, " Matches stored device, checking backup time...")
val lastBackupTime = settingsManager.lastBackupTime.value ?: 0
val backupMillis = System.currentTimeMillis() - lastBackupTime
if (backupMillis >= settingsManager.backupFrequencyInMillis) {
Expand All @@ -54,52 +72,10 @@ class UsbIntentReceiver : UsbMonitor() {
false
}
} else {
Log.d(TAG, "Different device attached, ignoring...")
Log.d(TAG, " Different device attached, ignoring...")
false
}
}

override fun onStatusChanged(context: Context, action: String, device: UsbDevice) {
requestFilesAndAppBackup(context, settingsManager, backupManager)
}

}

/**
* 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, UsbDevice::class.java) ?: 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 handler = Handler(Looper.getMainLooper())
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 {
Expand Down
74 changes: 74 additions & 0 deletions app/src/main/java/com/stevesoltys/seedvault/UsbMonitorService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/

package com.stevesoltys.seedvault

import android.app.Service
import android.app.backup.IBackupManager
import android.content.Intent
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST
import android.database.ContentObserver
import android.net.Uri
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.util.Log
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.ui.notification.NOTIFICATION_ID_USB_MONITOR
import com.stevesoltys.seedvault.worker.BackupRequester.Companion.requestFilesAndAppBackup
import org.koin.android.ext.android.inject

private const val TAG = "UsbMonitorService"

class UsbMonitorService : Service() {

private val nm: BackupNotificationManager by inject()
private val backupManager: IBackupManager by inject()
private val settingsManager: SettingsManager by inject()

override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
Log.d(TAG, "onStartCommand $intent $flags $startId")
startForeground(
NOTIFICATION_ID_USB_MONITOR,
nm.getUsbMonitorNotification(),
FOREGROUND_SERVICE_TYPE_MANIFEST,
)
val rootsUri = intent.data ?: error("No URI in start intent.")
val contentResolver = contentResolver
val handler = Handler(Looper.myLooper() ?: error("no looper"))
val observer = object : ContentObserver(handler) {
override fun onChange(selfChange: Boolean, uri: Uri?) {
super.onChange(selfChange, uri)
Log.i(TAG, "onChange() requesting backup now!")
contentResolver.unregisterContentObserver(this)
requestFilesAndAppBackup(applicationContext, settingsManager, backupManager)
Log.i(TAG, "stopSelf($startId)")
stopSelf(startId)
}
}
contentResolver.registerContentObserver(rootsUri, true, observer)

return START_NOT_STICKY
}

override fun onTimeout(startId: Int) {
Log.i(TAG, "onTimeout($startId)")
}

override fun onTimeout(startId: Int, fgsType: Int) {
Log.i(TAG, "onTimeout($startId, $fgsType)")
}

override fun onBind(intent: Intent?): IBinder? {
return null
}

override fun onDestroy() {
Log.d(TAG, "onDestroy")
super.onDestroy()
nm.cancelUsbMonitorNotification()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
package com.stevesoltys.seedvault.storage

import android.content.Intent
import android.util.Log
import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.worker.AppBackupWorker
import kotlinx.coroutines.flow.MutableStateFlow
Expand Down Expand Up @@ -64,6 +65,8 @@ internal class StorageBackupService : BackupService() {
override fun onBackupFinished(intent: Intent, success: Boolean) {
if (intent.getBooleanExtra(EXTRA_START_APP_BACKUP, false)) {
val isUsb = backendManager.backendProperties?.isUsb ?: false
Log.i("StorageBackupService", "backup finished (success: $success)")
Log.i("StorageBackupService", " starting AppBackupWorker...")
AppBackupWorker.scheduleNow(applicationContext, reschedule = !isUsb)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import android.util.Log
import androidx.core.app.NotificationCompat.Action
import androidx.core.app.NotificationCompat.Builder
import androidx.core.app.NotificationCompat.CATEGORY_ERROR
import androidx.core.app.NotificationCompat.FOREGROUND_SERVICE_DEFERRED
import androidx.core.app.NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
import androidx.core.app.NotificationCompat.PRIORITY_DEFAULT
import androidx.core.app.NotificationCompat.PRIORITY_HIGH
Expand Down Expand Up @@ -59,7 +60,8 @@ private const val NOTIFICATION_ID_RESTORE_ERROR = 6
internal const val NOTIFICATION_ID_PRUNING = 7
internal const val NOTIFICATION_ID_CHECKING = 8
internal const val NOTIFICATION_ID_CHECK_FINISHED = 9
private const val NOTIFICATION_ID_NO_MAIN_KEY_ERROR = 10
internal const val NOTIFICATION_ID_USB_MONITOR = 10
private const val NOTIFICATION_ID_NO_MAIN_KEY_ERROR = 11

private val TAG = BackupNotificationManager::class.java.simpleName

Expand Down Expand Up @@ -340,6 +342,22 @@ internal class BackupNotificationManager(private val context: Context) {
}.build()
}

/**
* Due to [FOREGROUND_SERVICE_DEFERRED], the user is unlikely to see this.
*/
fun getUsbMonitorNotification(): Notification {
return Builder(context, CHANNEL_ID_ERROR).apply {
setSmallIcon(R.drawable.ic_usb)
setContentTitle(context.getString(R.string.notification_usb_monitor_title))
setContentText(context.getString(R.string.notification_usb_monitor_text))
setOngoing(true)
priority = PRIORITY_LOW
foregroundServiceBehavior = FOREGROUND_SERVICE_DEFERRED
}.build()
}

fun cancelUsbMonitorNotification() = nm.cancel(NOTIFICATION_ID_USB_MONITOR)

fun getCheckNotification() = Builder(context, CHANNEL_ID_CHECKING).apply {
setSmallIcon(R.drawable.ic_cloud_search)
setContentTitle(context.getString(R.string.notification_checking_title))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,11 @@ internal class BackupRequester(
val i = Intent(context, StorageBackupService::class.java)
// this starts an app backup afterwards
i.putExtra(EXTRA_START_APP_BACKUP, appBackupEnabled)
Log.i(TAG, "Starting foreground service for file backup...")
Log.i(TAG, " appBackupEnabled = $appBackupEnabled")
startForegroundService(context, i)
} else if (appBackupEnabled) {
Log.i(TAG, "File backup disabled, starting only AppBackupWorker...")
AppBackupWorker.scheduleNow(context, reschedule)
} else {
Log.d(TAG, "Neither files nor app backup enabled, do nothing.")
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,9 @@
<string name="notification_checking_error_text">Tap for details. We checked %1$s at an average speed of %2$s.</string>
<string name="notification_checking_action">Details</string>

<string name="notification_usb_monitor_title">Waiting for USB drive…</string>
<string name="notification_usb_monitor_text">Once your USB drive becomes available, we will start a backup.</string>

<string name="backup_app_check_success_intro">%1$d snapshots were found and %2$d%% of their data (%3$s) successfully verified:</string>
<string name="backup_app_check_success_disclaimer">Note: we cannot verify whether individual apps include all of their user data in the backup.</string>
<string name="backup_app_check_error_title" translatable="false">@string/notification_checking_error_title</string>
Expand Down

0 comments on commit f892b8a

Please sign in to comment.