From 88aaf623fd34665c7ce5208bc9728eaa4baf97e4 Mon Sep 17 00:00:00 2001 From: Andrew Gunnerson Date: Sat, 11 Apr 2026 21:29:52 -0400 Subject: [PATCH] DeviceStateTracker: Use exact alarms for the time schedule Inexact alarms would be preferable, but there is no way to set the maximum deviance from the desired time. For the default 1h cycle + 5m sync interval, AOSP will add a 3m45s delay to the 5m portion, but a 41m15s delay to the 55m portion. The latter is so wildly different from what the user configured that the number has no meaning anymore. Instead, we'll just use exact alarms. The ability to do this is automatically granted when the user disables battery optimizations, but this commit also adds the SCHEDULE_EXACT_ALARM permission for users who don't do that. They will need to manually grant the permission from Android's settings because disabling battery optimizations is the (strongly) recommended setup. If exact alarms are not allowed, then inexact alarms will be used as a fallback. The user will now be responsible for choosing values that are sane and don't wake up the device too often because these alarms will interrupt doze mode. Fixes: #83 Signed-off-by: Andrew Gunnerson --- README.md | 3 + app/src/main/AndroidManifest.xml | 3 + .../basicsync/syncthing/DeviceState.kt | 60 ++++++++++++++----- 3 files changed, 52 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 72d89f7..22d61dc 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,9 @@ The app is intentionally kept very basic so that the project is easy to maintain * Optionally used for scanning a device's QR code when adding a new device. * `ACCESS_WIFI_STATE`, `ACCESS_COARSE_LOCATION`, `ACCESS_FINE_LOCATION`, `ACCESS_BACKGROUND_LOCATION`, `FOREGROUND_SERVICE_LOCATION` * Optionally used for stopping Syncthing unless connected to specific Wi-Fi networks. +* `SCHEDULE_EXACT_ALARM` + * Optionally used for the time schedule feature. Otherwise, Android may significantly delay both the start and end of the time windows. + * The app will not prompt for this permission because it is only needed when battery optimizations are still enabled, which is strongly discouraged anyway. ## Remote web UI access diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9859815..103b80a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -51,6 +51,9 @@ tools:ignore="BackgroundLocationPolicy" /> + + + diff --git a/app/src/main/java/com/chiller3/basicsync/syncthing/DeviceState.kt b/app/src/main/java/com/chiller3/basicsync/syncthing/DeviceState.kt index 7c44ba7..9c4e0e2 100644 --- a/app/src/main/java/com/chiller3/basicsync/syncthing/DeviceState.kt +++ b/app/src/main/java/com/chiller3/basicsync/syncthing/DeviceState.kt @@ -6,6 +6,7 @@ package com.chiller3.basicsync.syncthing import android.app.AlarmManager +import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.ContentResolver import android.content.Context @@ -26,6 +27,8 @@ import android.os.Looper import android.os.PowerManager import android.os.SystemClock import android.util.Log +import androidx.core.app.AlarmManagerCompat +import androidx.core.content.ContextCompat import com.chiller3.basicsync.Permissions import com.chiller3.basicsync.Preferences import com.chiller3.basicsync.R @@ -212,6 +215,8 @@ class DeviceStateTracker(private val context: Context) : const val MINIMUM_CYCLE_MS = 2 * MIN_FUTURITY const val MINIMUM_SYNC_MS = MIN_FUTURITY + private var alarmId = 0 + private fun getProxyInfo(): ProxyInfo { val proxyHost = System.getProperty("http.proxyHost") val proxyPort = System.getProperty("http.proxyPort") @@ -401,9 +406,22 @@ class DeviceStateTracker(private val context: Context) : private var autoSyncHandle: Any? = null - private val timeScheduleListener = object : AlarmManager.OnAlarmListener { - override fun onAlarm() { - alarmManager.cancel(this) + private val timeScheduleAction = "${javaClass.canonicalName}.ALARM.$alarmId".apply { + alarmId += 1 + } + private val timeSchedulePendingIntent = PendingIntent.getBroadcast( + context, + 0, + Intent(timeScheduleAction).apply { + setPackage(context.packageName) + }, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + // The OnAlarmListener variant of setExactAndAllowWhileIdle() isn't available until API 37. + private val timeScheduleReceiver: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + alarmManager.cancel(timeSchedulePendingIntent) val canRun = if (prefs.syncSchedule) { val cycleDurationMs = max(prefs.scheduleCycleMs, MINIMUM_CYCLE_MS) @@ -417,15 +435,22 @@ class DeviceStateTracker(private val context: Context) : now + (cycleDurationMs - syncDurationMs) } - alarmManager.set( - AlarmManager.ELAPSED_REALTIME, - wake, - "time_schedule", - this, - handler, - ) + val exact = AlarmManagerCompat.canScheduleExactAlarms(alarmManager) + if (exact) { + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.ELAPSED_REALTIME_WAKEUP, + wake, + timeSchedulePendingIntent, + ) + } else { + alarmManager.setAndAllowWhileIdle( + AlarmManager.ELAPSED_REALTIME_WAKEUP, + wake, + timeSchedulePendingIntent, + ) + } - Log.d(TAG, "Time window updated: now=$now, inWindow=$inWindow, preferredWake=$wake") + Log.d(TAG, "Time window updated: now=$now, inWindow=$inWindow, wake=$wake, exact=$exact") inWindow } else { Log.d(TAG, "Time schedule is disabled") @@ -440,7 +465,7 @@ class DeviceStateTracker(private val context: Context) : Log.d(TAG, "Resetting time schedule listener") state = state.copy(isInTimeWindow = false) - timeScheduleListener.onAlarm() + timeScheduleReceiver.onReceive(context, Intent()) } private val proxyChangeReceiver = object : BroadcastReceiver() { @@ -507,7 +532,13 @@ class DeviceStateTracker(private val context: Context) : autoSyncObserver, ) autoSyncObserver.onStatusChanged(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS) - timeScheduleListener.onAlarm() + ContextCompat.registerReceiver( + context, + timeScheduleReceiver, + IntentFilter(timeScheduleAction), + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + timeScheduleReceiver.onReceive(context, Intent()) context.registerReceiver( proxyChangeReceiver, IntentFilter(Proxy.PROXY_CHANGE_ACTION), @@ -528,7 +559,8 @@ class DeviceStateTracker(private val context: Context) : context.unregisterReceiver(batteryStatusReceiver) context.unregisterReceiver(batterySaverReceiver) ContentResolver.removeStatusChangeListener(autoSyncHandle) - alarmManager.cancel(timeScheduleListener) + context.unregisterReceiver(timeScheduleReceiver) + alarmManager.cancel(timeSchedulePendingIntent) handler.removeCallbacks(timeScheduleReset) context.unregisterReceiver(proxyChangeReceiver)