From c210b764bb1c14824e1adadf9d068e2ffdf565ba Mon Sep 17 00:00:00 2001 From: Andrew Gunnerson Date: Sun, 11 Oct 2026 00:47:17 -0400 Subject: [PATCH] Show run state transition timestamps in the notification This adds 2 new additional lines to the notification body: * "Started at" and "Stopped at" to indicate when the last run state change occurred. * "Starting at" and "Stopping at" to indicate when Syncthing will start or stop with the sync schedule feature enabled. To avoid confusion, the former is only shown if the sync schedule window is the only reason that Syncthing cannot currently run. Issue: #268 Signed-off-by: Andrew Gunnerson --- .../com/chiller3/basicsync/Notifications.kt | 138 ++++++++++++++---- .../basicsync/settings/SettingsScreen.kt | 2 + .../basicsync/syncthing/DeviceState.kt | 51 +++++-- .../basicsync/syncthing/SyncthingService.kt | 24 ++- app/src/main/res/values/strings.xml | 9 ++ 5 files changed, 179 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/com/chiller3/basicsync/Notifications.kt b/app/src/main/java/com/chiller3/basicsync/Notifications.kt index 365e00f..b284ded 100644 --- a/app/src/main/java/com/chiller3/basicsync/Notifications.kt +++ b/app/src/main/java/com/chiller3/basicsync/Notifications.kt @@ -11,12 +11,22 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.icu.text.DateTimePatternGenerator +import android.icu.util.ULocale import android.os.Build +import android.text.format.DateFormat +import androidx.annotation.StringRes import com.chiller3.basicsync.extension.toSingleLineString import com.chiller3.basicsync.settings.ConflictsActivity import com.chiller3.basicsync.settings.SettingsActivity import com.chiller3.basicsync.settings.WebUiActivity +import com.chiller3.basicsync.syncthing.BlockedReason +import com.chiller3.basicsync.syncthing.ScheduleEvent import com.chiller3.basicsync.syncthing.SyncthingService +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeFormatterBuilder class Notifications(private val context: Context) { companion object { @@ -93,6 +103,37 @@ class Notifications(private val context: Context) { LEGACY_CHANNEL_IDS.forEach { notificationManager.deleteNotificationChannel(it) } } + private fun getTimeFormatter(): DateTimeFormatter { + val uLocale = ULocale.forLocale(context.resources.configuration.locales[0]) + val generator = DateTimePatternGenerator.getInstance(uLocale) + val pattern = if (DateFormat.is24HourFormat(context)) { + generator.getBestPattern("Hms") + } else { + generator.getBestPattern("hms") + } + + return DateTimeFormatterBuilder() + .appendPattern(pattern) + .toFormatter() + } + + private fun formatTransition( + transition: ScheduleEvent, + @StringRes startResId: Int, + @StringRes stopResId: Int, + ): String { + val instant = Instant.ofEpochMilli(transition.timeMs) + val dateTime = instant.atZone(ZoneId.systemDefault()) + val timeFormatter = getTimeFormatter() + + return when (transition) { + is ScheduleEvent.WindowStart -> + context.getString(startResId, timeFormatter.format(dateTime)) + is ScheduleEvent.WindowEnd, is ScheduleEvent.IdleEnd -> + context.getString(stopResId, timeFormatter.format(dateTime)) + } + } + fun createPersistentNotification(state: SyncthingService.ServiceState): Pair { val runState = state.runState val titleResId = when (runState) { @@ -117,40 +158,81 @@ class Notifications(private val context: Context) { setOngoing(true) setOnlyAlertOnce(true) - if (state.showDetails && runState.showFolderStates) { - setContentText(buildString { - append(context.resources.getQuantityString( - R.plurals.device_state_connected, - state.deviceStates.connected, - state.deviceStates.connected, - )) - - for ((resId, count) in arrayOf( - R.plurals.device_state_syncing to state.deviceStates.syncing, - R.plurals.device_state_pending to state.deviceStates.pending, - R.plurals.folder_state_idle to state.folderStates.idle, - R.plurals.folder_state_scanning to state.folderStates.scanning, - R.plurals.folder_state_syncing to state.folderStates.syncing, - R.plurals.folder_state_cleaning to state.folderStates.cleaning, - R.plurals.folder_state_errored to state.folderStates.errored, - R.plurals.folder_state_starting to state.folderStates.starting, - )) { - if (count > 0) { - append('\n') - append(context.resources.getQuantityString(resId, count, count)) - } - } - }) - style = Notification.BigTextStyle() - } else if (runState.showBlockedReasons) { - setContentText(buildString { + val text = buildString { + var nonScheduleBlockedReasons = false + + if (runState.showBlockedReasons) { for ((i, reason) in state.blockedReasons.withIndex()) { if (i > 0) { append('\n') } append(reason.toString(context.resources)) + + if (reason != BlockedReason.TIME_SCHEDULE) { + nonScheduleBlockedReasons = true + } } - }) + } + + if (state.showDetails) { + if (runState.showFolderStates) { + if (isNotEmpty()) { + append('\n') + } + + append(context.resources.getQuantityString( + R.plurals.device_state_connected, + state.deviceStates.connected, + state.deviceStates.connected, + )) + + for ((resId, count) in arrayOf( + R.plurals.device_state_syncing to state.deviceStates.syncing, + R.plurals.device_state_pending to state.deviceStates.pending, + R.plurals.folder_state_idle to state.folderStates.idle, + R.plurals.folder_state_scanning to state.folderStates.scanning, + R.plurals.folder_state_syncing to state.folderStates.syncing, + R.plurals.folder_state_cleaning to state.folderStates.cleaning, + R.plurals.folder_state_errored to state.folderStates.errored, + R.plurals.folder_state_starting to state.folderStates.starting, + )) { + if (count > 0) { + append('\n') + append(context.resources.getQuantityString(resId, count, count)) + } + } + } + + if (state.lastTransition != null) { + if (isNotEmpty()) { + append('\n') + } + + append(formatTransition( + state.lastTransition, + R.string.timestamp_started_at, + R.string.timestamp_stopped_at, + )) + } + + // Don't show a misleading line about when the next run will be if there are + // other reasons that Syncthing cannot run. + if (state.nextTransition != null && !nonScheduleBlockedReasons) { + if (isNotEmpty()) { + append('\n') + } + + append(formatTransition( + state.nextTransition, + R.string.timestamp_starting_at, + R.string.timestamp_stopping_at, + )) + } + } + } + + if (text.isNotEmpty()) { + setContentText(text) style = Notification.BigTextStyle() } diff --git a/app/src/main/java/com/chiller3/basicsync/settings/SettingsScreen.kt b/app/src/main/java/com/chiller3/basicsync/settings/SettingsScreen.kt index 26b81d1..88be74f 100644 --- a/app/src/main/java/com/chiller3/basicsync/settings/SettingsScreen.kt +++ b/app/src/main/java/com/chiller3/basicsync/settings/SettingsScreen.kt @@ -1067,6 +1067,8 @@ private fun PreviewSettingsScreen() { val serviceState = SyncthingService.ServiceState( keepAlive = false, blockedReasons = EnumSet.noneOf(BlockedReason::class.java), + lastTransition = null, + nextTransition = null, isStarted = true, isResumed = true, manualMode = false, 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 0b91432..5ff7eb2 100644 --- a/app/src/main/java/com/chiller3/basicsync/syncthing/DeviceState.kt +++ b/app/src/main/java/com/chiller3/basicsync/syncthing/DeviceState.kt @@ -85,6 +85,16 @@ enum class BlockedReason { get() = this == NO_STORAGE_PERMISSIONS } +sealed interface ScheduleEvent { + val timeMs: Long + + data class WindowStart(override val timeMs: Long) : ScheduleEvent + + data class WindowEnd(override val timeMs: Long) : ScheduleEvent + + data class IdleEnd(override val timeMs: Long) : ScheduleEvent +} + data class DeviceState( val isNetworkConnected: Boolean = false, val isNetworkUnmetered: Boolean = false, @@ -96,6 +106,7 @@ data class DeviceState( val isAutoSyncData: Boolean = false, val isInTimeWindow: Boolean = false, val idleStart: Long = -1, + val nextTransition: ScheduleEvent? = null, val busyFolders: Int = 0, val connectedDevices: Int = 0, val connectedOnce: Boolean = false, @@ -444,7 +455,10 @@ class DeviceStateTracker(private val context: Context) : scheduleAction -> { alarmManager.cancel(schedulePendingIntent) - val canRun = if (prefs.syncSchedule) { + val nextTransition: ScheduleEvent? + val canRun: Boolean + + if (prefs.syncSchedule) { val cycleDurationMs = max(prefs.scheduleCycleMs, MINIMUM_CYCLE_MS) val syncDurationMs = max(prefs.scheduleSyncMs, MINIMUM_SYNC_MS) val syncIdleMs = max(prefs.scheduleIdleMs, MINIMUM_IDLE_MS) @@ -463,46 +477,55 @@ class DeviceStateTracker(private val context: Context) : val inWindow = now in windowStart until windowEnd - val (label, nextCheckMs) = if (!inWindow) { - "window_start" to windowStart + cycleDurationMs + nextTransition = if (!inWindow) { + ScheduleEvent.WindowStart(windowStart + cycleDurationMs) } else if (!endIsIdle) { - "window_end" to windowEnd + ScheduleEvent.WindowEnd(windowEnd) } else { - "idle_end" to windowEnd + ScheduleEvent.IdleEnd(windowEnd) } val exact = AlarmManagerCompat.canScheduleExactAlarms(alarmManager) if (exact) { alarmManager.setExactAndAllowWhileIdle( AlarmManager.RTC_WAKEUP, - nextCheckMs, + nextTransition.timeMs, schedulePendingIntent, ) } else { alarmManager.setAndAllowWhileIdle( AlarmManager.RTC_WAKEUP, - nextCheckMs, + nextTransition.timeMs, schedulePendingIntent, ) } - Log.d(TAG, "Scheduled next alarm: for=$label, wait=${nextCheckMs - now}, " + - "inWindow=$inWindow, exact=$exact") + Log.d(TAG, "Scheduled next alarm: transition=${nextTransition}, " + + "wait=${nextTransition.timeMs - now}, inWindow=$inWindow, " + + "exact=$exact") - inWindow + canRun = inWindow } else { Log.d(TAG, "Sync schedule is disabled") - true + + nextTransition = null + canRun = true } - if (!state.isInTimeWindow && canRun) { - state = state.copy( + state = if (!state.isInTimeWindow && canRun) { + state.copy( isInTimeWindow = true, idleStart = -1, + nextTransition = nextTransition, connectedOnce = false, ) } else if (state.isInTimeWindow && !canRun) { - state = state.copy(isInTimeWindow = false) + state.copy( + isInTimeWindow = false, + nextTransition = nextTransition, + ) + } else { + state.copy(nextTransition = nextTransition) } } } diff --git a/app/src/main/java/com/chiller3/basicsync/syncthing/SyncthingService.kt b/app/src/main/java/com/chiller3/basicsync/syncthing/SyncthingService.kt index d02c478..5188ec4 100644 --- a/app/src/main/java/com/chiller3/basicsync/syncthing/SyncthingService.kt +++ b/app/src/main/java/com/chiller3/basicsync/syncthing/SyncthingService.kt @@ -176,7 +176,7 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener get() = this == RUNNING || this == STARTING val showFolderStates: Boolean - get() = this == RUNNING + get() = this == RUNNING || this == PAUSED val showBlockedReasons: Boolean get() = this == NOT_RUNNING || this == PAUSED @@ -188,6 +188,8 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener data class ServiceState( private val keepAlive: Boolean, val blockedReasons: EnumSet, + val lastTransition: ScheduleEvent?, + val nextTransition: ScheduleEvent?, private val isStarted: Boolean, private val isResumed: Boolean, private val manualMode: Boolean, @@ -213,7 +215,9 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener && useLocation == prev.useLocation && showDetails == prev.showDetails && showExit == prev.showExit - && (!showDetails || (folderStates == prev.folderStates + && (!showDetails || (lastTransition == prev.lastTransition + && nextTransition == prev.nextTransition + && folderStates == prev.folderStates && deviceStates == prev.deviceStates)) private val shouldResume: Boolean @@ -479,6 +483,8 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener @GuardedBy("stateLock") private var syncthingApp: SyncthingApp? = null @GuardedBy("stateLock") + private var lastTransition: ScheduleEvent? = null + @GuardedBy("stateLock") private var syncthingConflicts = ConflictsInfo() set(conflicts) { if (field != conflicts) { @@ -723,12 +729,16 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener return } + val isManualMode = prefs.isManualMode + val serviceState = ServiceState( keepAlive = prefs.keepAlive, blockedReasons = blockedReasons, + lastTransition = lastTransition, + nextTransition = deviceState.nextTransition.takeIf { !isManualMode }, isStarted = isStarted, isResumed = isResumed, - manualMode = prefs.isManualMode, + manualMode = isManualMode, manualShouldRun = prefs.manualShouldRun, allowAutoMode = prefs.allowAutoMode, preRunAction = currentPreRunAction, @@ -794,6 +804,12 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener if (!needFullRestart && app != null && prefs.keepAlive) { Log.d(TAG, "Keep alive enabled; changing connect allowed to $shouldResume") app.isConnectAllowed = shouldResume + + lastTransition = if (shouldResume) { + ScheduleEvent.WindowStart(System.currentTimeMillis()) + } else { + ScheduleEvent.WindowEnd(System.currentTimeMillis()) + } } else if (app != null) { Log.d(TAG, "Syncthing is running; stopping service") app.stopAsync() @@ -949,6 +965,7 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener synchronized(stateLock) { syncthingApp = app + lastTransition = ScheduleEvent.WindowStart(System.currentTimeMillis()) stateChanged() } @@ -964,6 +981,7 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener syncthingFolderStates = FolderStates() syncthingDeviceStates = DeviceStates() syncthingApp = null + lastTransition = ScheduleEvent.WindowEnd(System.currentTimeMillis()) stateChanged() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 38147c2..a78a786 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -384,6 +384,15 @@ • %d devices pending sync + + • Started at %s + + • Stopped at %s + + • Starting at %s + + • Stopping at %s + Auto mode