From f5864f6fa8dd140c187def520071eb77e1a9bea1 Mon Sep 17 00:00:00 2001 From: Andrew Gunnerson Date: Wed, 25 Feb 2026 23:50:06 -0500 Subject: [PATCH] Show reason that auto mode blocked syncthing from running The list of reasons will show up both on the main settings screen and the notification. Previously, the user had no way to know why syncthing wasn't running without looking at the logs. Signed-off-by: Andrew Gunnerson --- .../com/chiller3/basicsync/Notifications.kt | 17 +++- .../settings/ServiceBaseViewModel.kt | 10 +- .../basicsync/settings/SettingsFragment.kt | 59 ++++++----- .../basicsync/settings/WebUiActivity.kt | 6 +- .../basicsync/syncthing/DeviceState.kt | 99 +++++++++++++------ .../basicsync/syncthing/SyncthingService.kt | 60 +++++------ app/src/main/res/values/strings.xml | 12 +++ 7 files changed, 171 insertions(+), 92 deletions(-) diff --git a/app/src/main/java/com/chiller3/basicsync/Notifications.kt b/app/src/main/java/com/chiller3/basicsync/Notifications.kt index a20f824..f75c187 100644 --- a/app/src/main/java/com/chiller3/basicsync/Notifications.kt +++ b/app/src/main/java/com/chiller3/basicsync/Notifications.kt @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2022-2025 Andrew Gunnerson + * SPDX-FileCopyrightText: 2022-2026 Andrew Gunnerson * SPDX-License-Identifier: GPL-3.0-only */ @@ -62,7 +62,8 @@ class Notifications(private val context: Context) { } fun createPersistentNotification(state: SyncthingService.ServiceState): Notification { - val titleResId = when (state.runState) { + val runState = state.runState + val titleResId = when (runState) { SyncthingService.RunState.RUNNING -> R.string.notification_persistent_running_title SyncthingService.RunState.NOT_RUNNING -> R.string.notification_persistent_not_running_title SyncthingService.RunState.PAUSED -> R.string.notification_persistent_paused_title @@ -79,6 +80,18 @@ class Notifications(private val context: Context) { setOngoing(true) setOnlyAlertOnce(true) + if (runState.showBlockedReasons) { + setContentText(buildString { + for ((i, reason) in state.blockedReasons.withIndex()) { + if (i > 0) { + append('\n') + } + append(reason.toString(context)) + } + }) + style = Notification.BigTextStyle() + } + for (action in state.actions) { val actionTextResId = when (action) { SyncthingService.ACTION_AUTO_MODE -> R.string.notification_action_auto_mode diff --git a/app/src/main/java/com/chiller3/basicsync/settings/ServiceBaseViewModel.kt b/app/src/main/java/com/chiller3/basicsync/settings/ServiceBaseViewModel.kt index af38503..0c91264 100644 --- a/app/src/main/java/com/chiller3/basicsync/settings/ServiceBaseViewModel.kt +++ b/app/src/main/java/com/chiller3/basicsync/settings/ServiceBaseViewModel.kt @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2025 Andrew Gunnerson + * SPDX-FileCopyrightText: 2025-2026 Andrew Gunnerson * SPDX-License-Identifier: GPL-3.0-only */ @@ -20,8 +20,8 @@ abstract class ServiceBaseViewModel(application: Application) : AndroidViewModel ServiceConnection, SyncthingService.ServiceListener { protected var binder: SyncthingService.ServiceBinder? = null - private val _runState = MutableStateFlow(null) - val runState = _runState.asStateFlow() + private val _serviceState = MutableStateFlow(null) + val serviceState = _serviceState.asStateFlow() private val _guiInfo = MutableStateFlow(null) val guiInfo = _guiInfo.asStateFlow() @@ -59,10 +59,10 @@ abstract class ServiceBaseViewModel(application: Application) : AndroidViewModel } override fun onRunStateChanged( - state: SyncthingService.RunState, + state: SyncthingService.ServiceState, guiInfo: SyncthingService.GuiInfo?, ) { - _runState.update { state } + _serviceState.update { state } _guiInfo.update { guiInfo } } diff --git a/app/src/main/java/com/chiller3/basicsync/settings/SettingsFragment.kt b/app/src/main/java/com/chiller3/basicsync/settings/SettingsFragment.kt index 314939f..6a3558b 100644 --- a/app/src/main/java/com/chiller3/basicsync/settings/SettingsFragment.kt +++ b/app/src/main/java/com/chiller3/basicsync/settings/SettingsFragment.kt @@ -201,32 +201,45 @@ class SettingsFragment : PreferenceBaseFragment(), FragmentResultListener, lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.CREATED) { - viewModel.runState.collect { - prefOpenWebUi.isEnabled = it != null && it.webUiAvailable + viewModel.serviceState.collect { state -> + val runState = state?.runState - serviceAvailable = it != null + prefOpenWebUi.isEnabled = runState != null && runState.webUiAvailable + + serviceAvailable = runState != null refreshImportExport() - prefServiceStatus.isChecked = it == SyncthingService.RunState.RUNNING - || it == SyncthingService.RunState.STARTING - prefServiceStatus.summary = when (it) { - SyncthingService.RunState.RUNNING -> - getString(R.string.notification_persistent_running_title) - SyncthingService.RunState.NOT_RUNNING -> - getString(R.string.notification_persistent_not_running_title) - SyncthingService.RunState.PAUSED -> - getString(R.string.notification_persistent_paused_title) - SyncthingService.RunState.STARTING -> - getString(R.string.notification_persistent_starting_title) - SyncthingService.RunState.STOPPING -> - getString(R.string.notification_persistent_stopping_title) - SyncthingService.RunState.PAUSING -> - getString(R.string.notification_persistent_pausing_title) - SyncthingService.RunState.IMPORTING -> - getString(R.string.notification_persistent_importing_title) - SyncthingService.RunState.EXPORTING -> - getString(R.string.notification_persistent_exporting_title) - null -> null + prefServiceStatus.isChecked = runState == SyncthingService.RunState.RUNNING + || runState == SyncthingService.RunState.STARTING + prefServiceStatus.summary = runState?.let { + buildString { + val statusText = when (runState) { + SyncthingService.RunState.RUNNING -> + getString(R.string.notification_persistent_running_title) + SyncthingService.RunState.NOT_RUNNING -> + getString(R.string.notification_persistent_not_running_title) + SyncthingService.RunState.PAUSED -> + getString(R.string.notification_persistent_paused_title) + SyncthingService.RunState.STARTING -> + getString(R.string.notification_persistent_starting_title) + SyncthingService.RunState.STOPPING -> + getString(R.string.notification_persistent_stopping_title) + SyncthingService.RunState.PAUSING -> + getString(R.string.notification_persistent_pausing_title) + SyncthingService.RunState.IMPORTING -> + getString(R.string.notification_persistent_importing_title) + SyncthingService.RunState.EXPORTING -> + getString(R.string.notification_persistent_exporting_title) + } + append(statusText) + + if (runState.showBlockedReasons) { + for (reason in state.blockedReasons) { + append('\n') + append(reason.toString(context)) + } + } + } } } } diff --git a/app/src/main/java/com/chiller3/basicsync/settings/WebUiActivity.kt b/app/src/main/java/com/chiller3/basicsync/settings/WebUiActivity.kt index 481fe18..c3fee99 100644 --- a/app/src/main/java/com/chiller3/basicsync/settings/WebUiActivity.kt +++ b/app/src/main/java/com/chiller3/basicsync/settings/WebUiActivity.kt @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2025 Andrew Gunnerson + * SPDX-FileCopyrightText: 2025-2026 Andrew Gunnerson * SPDX-License-Identifier: GPL-3.0-only */ @@ -206,8 +206,8 @@ class WebUiActivity : AppCompatActivity() { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.CREATED) { - viewModel.runState.collect { - if (it != null && !it.webUiAvailable) { + viewModel.serviceState.collect { + if (it?.runState?.webUiAvailable == false) { finish() } } 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 4e6969a..212b641 100644 --- a/app/src/main/java/com/chiller3/basicsync/syncthing/DeviceState.kt +++ b/app/src/main/java/com/chiller3/basicsync/syncthing/DeviceState.kt @@ -27,6 +27,8 @@ import android.os.SystemClock import android.util.Log import com.chiller3.basicsync.Permissions import com.chiller3.basicsync.Preferences +import com.chiller3.basicsync.R +import java.util.EnumSet import kotlin.math.max import kotlin.math.roundToInt @@ -42,6 +44,36 @@ data class ProxyInfo( val noProxy: String, ) +enum class BlockedReason { + MANUAL, + DISCONNECTED, + METERED_NETWORK, + BAD_NETWORK_TYPE, + BAD_WIFI_SSID, + ON_BATTERY, + LOW_BATTERY, + BATTERY_SAVER, + AUTO_SYNC_DATA, + TIME_SCHEDULE; + + fun toString(context: Context): String { + val stringId = when (this) { + MANUAL -> R.string.blocked_reason_manual + DISCONNECTED -> R.string.blocked_reason_disconnected + METERED_NETWORK -> R.string.blocked_reason_metered_network + BAD_NETWORK_TYPE -> R.string.blocked_reason_bad_network_type + BAD_WIFI_SSID -> R.string.blocked_reason_bad_wifi_ssid + ON_BATTERY -> R.string.blocked_reason_on_battery + LOW_BATTERY -> R.string.blocked_reason_low_battery + BATTERY_SAVER -> R.string.blocked_reason_battery_saver + AUTO_SYNC_DATA -> R.string.blocked_reason_auto_sync_data + TIME_SCHEDULE -> R.string.blocked_reason_time_schedule + } + + return context.getString(stringId) + } +} + data class DeviceState( val isNetworkConnected: Boolean = false, val isNetworkUnmetered: Boolean = false, @@ -85,33 +117,35 @@ data class DeviceState( allowed == ssid?.let(::normalizeSsid) } - fun canRun(prefs: Preferences): Boolean { + fun blockedReasons(prefs: Preferences): EnumSet { + val reasons = EnumSet.noneOf(BlockedReason::class.java) + if (!isNetworkConnected) { Log.d(TAG, "Blocked due to lack of network connectivity") - return false - } - - if (prefs.requireUnmeteredNetwork && !isNetworkUnmetered) { - Log.d(TAG, "Blocked due to unmetered network requirement") - return false - } + reasons.add(BlockedReason.DISCONNECTED) + } else { + if (prefs.requireUnmeteredNetwork && !isNetworkUnmetered) { + Log.d(TAG, "Blocked due to unmetered network requirement") + reasons.add(BlockedReason.METERED_NETWORK) + } - val networkAllowed = when (networkType) { - NetworkType.WIFI -> prefs.networkAllowWifi - NetworkType.CELLULAR -> prefs.networkAllowCellular - NetworkType.ETHERNET -> prefs.networkAllowEthernet - NetworkType.OTHER -> prefs.networkAllowOther - } - if (!networkAllowed) { - Log.d(TAG, "Blocked due to disallowed network interface type: $networkType") - return false - } + val networkAllowed = when (networkType) { + NetworkType.WIFI -> prefs.networkAllowWifi + NetworkType.CELLULAR -> prefs.networkAllowCellular + NetworkType.ETHERNET -> prefs.networkAllowEthernet + NetworkType.OTHER -> prefs.networkAllowOther + } + if (!networkAllowed) { + Log.d(TAG, "Blocked due to disallowed network interface type: $networkType") + reasons.add(BlockedReason.BAD_NETWORK_TYPE) + } - val allowedWifiNetworks = prefs.allowedWifiNetworks - if (networkType == NetworkType.WIFI && allowedWifiNetworks.isNotEmpty() - && allowedWifiNetworks.none { ssidMatches(it, wifiSsid) }) { - Log.d(TAG, "Blocked due to disallowed network: ssid=$wifiSsid") - return false + val allowedWifiNetworks = prefs.allowedWifiNetworks + if (networkType == NetworkType.WIFI && allowedWifiNetworks.isNotEmpty() + && allowedWifiNetworks.none { ssidMatches(it, wifiSsid) }) { + Log.d(TAG, "Blocked due to disallowed network: ssid=$wifiSsid") + reasons.add(BlockedReason.BAD_WIFI_SSID) + } } if (!isPluggedIn) { @@ -120,30 +154,35 @@ data class DeviceState( if (!runOnBattery) { Log.d(TAG, "Blocked due to battery power source") - return false + reasons.add(BlockedReason.ON_BATTERY) } else if (batteryLevel < minBatteryLevel) { Log.d(TAG, "Blocked due to low battery level: $batteryLevel < $minBatteryLevel") - return false + reasons.add(BlockedReason.LOW_BATTERY) } } if (prefs.respectBatterySaver && isBatterySaver) { Log.d(TAG, "Blocked due to battery saver mode") - return false + reasons.add(BlockedReason.BATTERY_SAVER) } if (prefs.respectAutoSyncData && !isAutoSyncData) { Log.d(TAG, "Blocked due to auto-sync data status") - return false + reasons.add(BlockedReason.AUTO_SYNC_DATA) } if (!isInTimeWindow) { Log.d(TAG, "Blocked due to execution time window") - return false + reasons.add(BlockedReason.TIME_SCHEDULE) + } + + if (reasons.isEmpty()) { + Log.d(TAG, "Permitted to run") + } else { + Log.d(TAG, "Blocked reasons: $reasons") } - Log.d(TAG, "Permitted to run") - return true + return reasons } } 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 45a02fe..fb048bf 100644 --- a/app/src/main/java/com/chiller3/basicsync/syncthing/SyncthingService.kt +++ b/app/src/main/java/com/chiller3/basicsync/syncthing/SyncthingService.kt @@ -26,6 +26,7 @@ import com.chiller3.basicsync.binding.stbridge.SyncthingApp import com.chiller3.basicsync.binding.stbridge.SyncthingStartupConfig import com.chiller3.basicsync.binding.stbridge.SyncthingStatusReceiver import java.io.IOException +import java.util.EnumSet class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener, SharedPreferences.OnSharedPreferenceChangeListener { @@ -64,18 +65,24 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener IMPORTING, EXPORTING; + val showBlockedReasons: Boolean + get() = this == NOT_RUNNING || this == PAUSED + val webUiAvailable: Boolean get() = this == RUNNING || this == PAUSED || this == PAUSING } data class ServiceState( - val keepAlive: Boolean, - val shouldRun: Boolean, - val isStarted: Boolean, - val isActive: Boolean, - val manualMode: Boolean, - val preRunAction: PreRunAction?, + private val keepAlive: Boolean, + val blockedReasons: EnumSet, + private val isStarted: Boolean, + private val isActive: Boolean, + private val manualMode: Boolean, + private val preRunAction: PreRunAction?, ) { + private val shouldRun: Boolean + get() = blockedReasons.isEmpty() + val runState: RunState get() = if (preRunAction != null) { when (preRunAction) { @@ -144,11 +151,7 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener ?: throw IOException("Failed to open for reading: $uri") // stbridge will own the fd. - Stbridge.importConfiguration( - fd.detachFd().toLong(), - uri.toString(), - password?.value ?: "", - ) + Stbridge.importConfiguration(fd.detachFd().toLong(), uri.toString(), password.value) } } @@ -159,11 +162,7 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener ?: throw IOException("Failed to open for writing: $uri") // stbridge will own the fd. - Stbridge.exportConfiguration( - fd.detachFd().toLong(), - uri.toString(), - password?.value ?: "", - ) + Stbridge.exportConfiguration(fd.detachFd().toLong(), uri.toString(), password.value) } } } @@ -188,18 +187,22 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener @GuardedBy("stateLock") private var runningProxyInfo: ProxyInfo? = null - private val autoShouldRun: Boolean - @GuardedBy("stateLock") - get() = deviceState.canRun(prefs) - - private val shouldRun: Boolean + private val blockedReasons: EnumSet @GuardedBy("stateLock") get() = if (prefs.isManualMode) { - prefs.manualShouldRun + if (prefs.manualShouldRun) { + EnumSet.noneOf(BlockedReason::class.java) + } else { + EnumSet.of(BlockedReason.MANUAL) + } } else { - autoShouldRun + deviceState.blockedReasons(prefs) } + private val shouldRun: Boolean + @GuardedBy("stateLock") + get() = blockedReasons.isEmpty() + private val shouldStart: Boolean @GuardedBy("stateLock") get() = prefs.keepAlive || shouldRun @@ -275,7 +278,7 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener ACTION_MANUAL_MODE -> { // Keep the current state since the user has no way to know what the previously // saved state is anyway. - prefs.manualShouldRun = autoShouldRun + prefs.manualShouldRun = shouldRun prefs.isManualMode = true } ACTION_START -> prefs.manualShouldRun = true @@ -329,7 +332,7 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener val notificationState = ServiceState( keepAlive = prefs.keepAlive, - shouldRun = shouldRun, + blockedReasons = blockedReasons, isStarted = isStarted, isActive = isActive, manualMode = prefs.isManualMode, @@ -342,11 +345,10 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener forceShowNotification = false if (wasChanged) { - val runState = notificationState.runState val guiInfo = guiInfo for (listener in listeners) { - listener.onRunStateChanged(runState, guiInfo) + listener.onRunStateChanged(notificationState, guiInfo) } } @@ -519,7 +521,7 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener } interface ServiceListener { - fun onRunStateChanged(state: RunState, guiInfo: GuiInfo?) + fun onRunStateChanged(state: ServiceState, guiInfo: GuiInfo?) fun onPreRunActionResult(preRunAction: PreRunAction, exception: Exception?) } @@ -533,7 +535,7 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener Log.w(TAG, "Listener was already registered: $listener") } - listener.onRunStateChanged(lastServiceState!!.runState, guiInfo) + listener.onRunStateChanged(lastServiceState!!, guiInfo) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d575234..8f8fa4f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -122,6 +122,18 @@ Start Stop + + • Manually stopped + • Not connected to network + • Connected to metered network + • Network type not allowed + • Connected to blocked Wi-Fi network + • Running on battery power + • Battery level is low + • Battery saver mode is on + • Auto-sync data is off + • Outside of time schedule + %d second