Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 110 additions & 28 deletions app/src/main/java/com/chiller3/basicsync/Notifications.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Int, Notification> {
val runState = state.runState
val titleResId = when (runState) {
Expand All @@ -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()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
51 changes: 37 additions & 14 deletions app/src/main/java/com/chiller3/basicsync/syncthing/DeviceState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -188,6 +188,8 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener
data class ServiceState(
private val keepAlive: Boolean,
val blockedReasons: EnumSet<BlockedReason>,
val lastTransition: ScheduleEvent?,
val nextTransition: ScheduleEvent?,
private val isStarted: Boolean,
private val isResumed: Boolean,
private val manualMode: Boolean,
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -949,6 +965,7 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener

synchronized(stateLock) {
syncthingApp = app
lastTransition = ScheduleEvent.WindowStart(System.currentTimeMillis())

stateChanged()
}
Expand All @@ -964,6 +981,7 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener
syncthingFolderStates = FolderStates()
syncthingDeviceStates = DeviceStates()
syncthingApp = null
lastTransition = ScheduleEvent.WindowEnd(System.currentTimeMillis())

stateChanged()
}
Expand Down
9 changes: 9 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,15 @@
<item quantity="other">• %d devices pending sync</item>
</plurals>

<!-- Timestamp of when Syncthing was last started. -->
<string name="timestamp_started_at">• Started at %s</string>
<!-- Timestamp of when Syncthing was last stopped. -->
<string name="timestamp_stopped_at">• Stopped at %s</string>
<!-- Timestamp that Syncthing will start again when the sync schedule feature is enabled. -->
<string name="timestamp_starting_at">• Starting at %s</string>
<!-- Timestamp that Syncthing will stop again when the sync schedule feature is enabled. -->
<string name="timestamp_stopping_at">• Stopping at %s</string>

<!-- Text shown in quick settings tile when in auto mode. Keep text as short as possible. -->
<string name="quick_settings_tile_auto_mode">Auto mode</string>
<!-- Text shown in quick settings tile when in manual mode and Syncthing is started. Keep text as short as possible. -->
Expand Down
Loading