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