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)