From 2d70b8299551849a5ac73aee2248c3ba8899603a Mon Sep 17 00:00:00 2001 From: Andrew Gunnerson Date: Mon, 20 Jul 2026 23:52:12 -0400 Subject: [PATCH] DeviceState: Align sync intervals to unix timestamp 0 This allows multiple devices running BasicSync with the same intervals configured to communicate with each other. Previously, the intervals were aligned to the app start time or last sync schedule preference change time, whichever was later. This commit also makes the alarm receiver idempotent so we no longer need two separate alarms to handle the sync and idle intervals. Fixes: #207 Signed-off-by: Andrew Gunnerson --- .../basicsync/syncthing/DeviceState.kt | 159 +++++++++--------- 1 file changed, 81 insertions(+), 78 deletions(-) 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 05e0855..db77622 100644 --- a/app/src/main/java/com/chiller3/basicsync/syncthing/DeviceState.kt +++ b/app/src/main/java/com/chiller3/basicsync/syncthing/DeviceState.kt @@ -26,7 +26,6 @@ import android.os.Build import android.os.Handler 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 @@ -95,7 +94,7 @@ data class DeviceState( val isBatterySaver: Boolean = false, val isAutoSyncData: Boolean = false, val isInTimeWindow: Boolean = false, - val isIdleExpired: Boolean = false, + val idleStart: Long = -1, val busyFolders: Int = 0, val connectedDevices: Int = 0, val connectedOnce: Boolean = false, @@ -190,11 +189,11 @@ data class DeviceState( reasons.add(BlockedReason.AUTO_SYNC_DATA) } - if (!isInTimeWindow || isIdleExpired) { + if (!isInTimeWindow) { if (prefs.syncScheduleBatteryOnly && isPluggedIn) { Log.d(TAG, "Ignoring sync schedule because device is plugged in") } else { - Log.d(TAG, "Blocked due to execution time window (idleExpired=${isIdleExpired})") + Log.d(TAG, "Blocked due to execution time window") reasons.add(BlockedReason.TIME_SCHEDULE) } } @@ -428,31 +427,61 @@ class DeviceStateTracker(private val context: Context) : private var autoSyncHandle: Any? = null - private val scheduleCycleAction = newAlarmAction() - private val scheduleIdleAction = newAlarmAction() - private val scheduleCyclePendingIntent = alarmPendingIntent(context, scheduleCycleAction) - private val scheduleIdlePendingIntent = alarmPendingIntent(context, scheduleIdleAction) + private val scheduleAction = newAlarmAction() + private val schedulePendingIntent = alarmPendingIntent(context, scheduleAction) // The OnAlarmListener variant of setExactAndAllowWhileIdle() isn't available until API 37. private val scheduleReceiver: BroadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { when (intent.action) { - scheduleCycleAction -> { - alarmManager.cancel(scheduleCyclePendingIntent) - alarmManager.cancel(scheduleIdlePendingIntent) + // This is idempotent at a given point in time. + scheduleAction -> { + alarmManager.cancel(schedulePendingIntent) val canRun = if (prefs.syncSchedule) { val cycleDurationMs = max(prefs.scheduleCycleMs, MINIMUM_CYCLE_MS) val syncDurationMs = max(prefs.scheduleSyncMs, MINIMUM_SYNC_MS) - val inWindow = !state.isInTimeWindow + val syncIdleMs = max(prefs.scheduleIdleMs, MINIMUM_IDLE_MS) + + val now = System.currentTimeMillis() + val windowStart = now / cycleDurationMs * cycleDurationMs + var windowEnd = windowStart + syncDurationMs + var endIsIdle = false + if (state.idleStart >= 0) { + val idleEnd = state.idleStart + syncIdleMs + if (idleEnd in windowStart until windowEnd) { + windowEnd = idleEnd + endIsIdle = true + } + } + + val inWindow = now in windowStart until windowEnd + + val (label, nextCheckMs) = if (!inWindow) { + "window_start" to windowStart + cycleDurationMs + } else if (!endIsIdle) { + "window_end" to windowEnd + } else { + "idle_end" to windowEnd + } - val (label, durationMs) = if (inWindow) { - "sync" to syncDurationMs + val exact = AlarmManagerCompat.canScheduleExactAlarms(alarmManager) + if (exact) { + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + nextCheckMs, + schedulePendingIntent, + ) } else { - "off" to (cycleDurationMs - syncDurationMs) + alarmManager.setAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + nextCheckMs, + schedulePendingIntent, + ) } - scheduleAlarm(label, scheduleCyclePendingIntent, durationMs.toLong()) + Log.d(TAG, "Scheduled next alarm: for=$label, wait=${nextCheckMs - now}, " + + "inWindow=$inWindow, exact=$exact") inWindow } else { @@ -460,59 +489,39 @@ class DeviceStateTracker(private val context: Context) : true } - if (canRun) { - scheduleIdleTimer() + if (!state.isInTimeWindow && canRun) { + state = state.copy( + isInTimeWindow = true, + idleStart = -1, + connectedOnce = false, + ) + } else if (state.isInTimeWindow && !canRun) { + state = state.copy(isInTimeWindow = false) } - - state = state.copy( - isInTimeWindow = canRun, - isIdleExpired = false, - connectedOnce = false, - ) - } - scheduleIdleAction -> { - Log.d(TAG, "Idle window expired") - - state = state.copy(isIdleExpired = true) } } } } - private fun scheduleAlarm(type: String, pendingIntent: PendingIntent, sleepMs: Long) { - val now = SystemClock.elapsedRealtime() - val wake = now + sleepMs - - val exact = AlarmManagerCompat.canScheduleExactAlarms(alarmManager) - if (exact) { - alarmManager.setExactAndAllowWhileIdle( - AlarmManager.ELAPSED_REALTIME_WAKEUP, - wake, - pendingIntent, - ) + private fun updateIdleState() { + var idleStart = -1L + + if (!prefs.syncSchedule || !prefs.syncScheduleIdle) { + Log.d(TAG, "Idle schedule is disabled") + } else if (!state.isInTimeWindow) { + Log.d(TAG, "Outside of window; ignoring change in idle state") + } else if (!state.connectedOnce) { + Log.d(TAG, "Waiting for initial connection") + } else if (state.busyFolders > 0) { + Log.d(TAG, "Became busy") + } else if (state.idleStart < 0) { + Log.d(TAG, "Became idle") + idleStart = System.currentTimeMillis() } else { - alarmManager.setAndAllowWhileIdle( - AlarmManager.ELAPSED_REALTIME_WAKEUP, - wake, - pendingIntent, - ) + Log.d(TAG, "Already idle") } - Log.d(TAG, "Scheduled alarm: type=$type, now=$now, wake=$wake, exact=$exact") - } - - private fun scheduleIdleTimer() { - val enabled = prefs.syncSchedule && prefs.syncScheduleIdle - val isIdle = state.busyFolders == 0 - val connectedOnce = state.connectedOnce - - if (enabled && isIdle && connectedOnce) { - val syncIdleMs = max(prefs.scheduleIdleMs, MINIMUM_IDLE_MS) - - scheduleAlarm("idle", scheduleIdlePendingIntent, syncIdleMs.toLong()) - } else { - Log.d(TAG, "Not scheduling idle timer: enabled=$enabled, idle=$isIdle, connectedOnce=$connectedOnce") - } + state = state.copy(idleStart = idleStart) } fun updateBusyFolders(folderStates: SyncthingService.FolderStates) { @@ -521,12 +530,10 @@ class DeviceStateTracker(private val context: Context) : handler.post { if (state.busyFolders != count) { - alarmManager.cancel(scheduleIdlePendingIntent) - Log.d(TAG, "Busy folders updated: count=$count") state = state.copy(busyFolders = count) - scheduleIdleTimer() + scheduleRefresh.run() } } } @@ -548,17 +555,17 @@ class DeviceStateTracker(private val context: Context) : // After a single device connects, the idle timer is based solely on whether all // folders are idle. Devices disconnecting do not affect the timer. if (!connectedBefore && count > 0) { - scheduleIdleTimer() + scheduleRefresh.run() } } } } - private val scheduleReset = Runnable { - Log.d(TAG, "Resetting sync schedule listener") + private val scheduleRefresh = Runnable { + Log.d(TAG, "Refreshing sync schedule") - state = state.copy(isInTimeWindow = false, isIdleExpired = false) - scheduleReceiver.onReceive(context, Intent(scheduleCycleAction)) + updateIdleState() + scheduleReceiver.onReceive(context, Intent(scheduleAction)) } private val proxyChangeReceiver = object : BroadcastReceiver() { @@ -599,8 +606,8 @@ class DeviceStateTracker(private val context: Context) : Log.d(TAG, "Preference $key changed") // Try to debounce since these are generally changed at the same time. - handler.removeCallbacks(scheduleReset) - handler.post(scheduleReset) + handler.removeCallbacks(scheduleRefresh) + handler.post(scheduleRefresh) } // PREF_ALLOWED_WIFI_NETWORKS is checked in SyncthingService because we need the service // to request the location foreground permission first. @@ -630,13 +637,10 @@ class DeviceStateTracker(private val context: Context) : ContextCompat.registerReceiver( context, scheduleReceiver, - IntentFilter().apply { - addAction(scheduleCycleAction) - addAction(scheduleIdleAction) - }, + IntentFilter(scheduleAction), ContextCompat.RECEIVER_NOT_EXPORTED, ) - scheduleReceiver.onReceive(context, Intent(scheduleCycleAction)) + scheduleReceiver.onReceive(context, Intent(scheduleAction)) context.registerReceiver( proxyChangeReceiver, IntentFilter(Proxy.PROXY_CHANGE_ACTION), @@ -658,9 +662,8 @@ class DeviceStateTracker(private val context: Context) : context.unregisterReceiver(batterySaverReceiver) ContentResolver.removeStatusChangeListener(autoSyncHandle) context.unregisterReceiver(scheduleReceiver) - alarmManager.cancel(scheduleCyclePendingIntent) - alarmManager.cancel(scheduleIdlePendingIntent) - handler.removeCallbacks(scheduleReset) + alarmManager.cancel(schedulePendingIntent) + handler.removeCallbacks(scheduleRefresh) context.unregisterReceiver(proxyChangeReceiver) this.listener = null