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