diff --git a/app/src/main/java/com/chiller3/basicsync/Notifications.kt b/app/src/main/java/com/chiller3/basicsync/Notifications.kt index 5c3e4fe..2fa7506 100644 --- a/app/src/main/java/com/chiller3/basicsync/Notifications.kt +++ b/app/src/main/java/com/chiller3/basicsync/Notifications.kt @@ -26,12 +26,14 @@ class Notifications(private val context: Context) { private const val CHANNEL_ID_PERSISTENT = "persistent" private const val CHANNEL_ID_FAILURE = "failure" private const val CHANNEL_ID_CONFLICTS = "conflicts" + private const val CHANNEL_ID_ALERTS = "alerts" private val LEGACY_CHANNEL_IDS = arrayOf() const val ID_PERSISTENT = -1 private const val ID_FAILURE = -2 private const val ID_CONFLICTS = -3 + private const val ID_ALERTS = -4 } private val notificationManager = context.getSystemService(NotificationManager::class.java) @@ -62,6 +64,14 @@ class Notifications(private val context: Context) { description = context.getString(R.string.notification_channel_conflicts_desc) } + private fun createSyncthingAlertsChannel() = NotificationChannel( + CHANNEL_ID_ALERTS, + context.getString(R.string.notification_channel_alerts_name), + NotificationManager.IMPORTANCE_HIGH, + ).apply { + description = context.getString(R.string.notification_channel_alerts_desc) + } + /** * Ensure notification channels are up-to-date. * @@ -72,6 +82,7 @@ class Notifications(private val context: Context) { createPersistentChannel(), createFailureAlertsChannel(), createConflictsAlertsChannel(), + createSyncthingAlertsChannel(), )) LEGACY_CHANNEL_IDS.forEach { notificationManager.deleteNotificationChannel(it) } } @@ -240,4 +251,36 @@ class Notifications(private val context: Context) { notificationManager.notify(ID_CONFLICTS, notification) } + + fun sendOrClearAlertsNotification(alertCount: Int) { + if (alertCount == 0) { + notificationManager.cancel(ID_ALERTS) + return + } + + val notification = Notification.Builder(context, CHANNEL_ID_ALERTS).run { + setContentTitle(context.resources.getQuantityString( + R.plurals.notification_syncthing_alerts_title, + alertCount, + alertCount, + )) + setSmallIcon(R.drawable.ic_notifications) + setOnlyAlertOnce(true) + + val intent = Intent(context, WebUiActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + val pendingIntent = PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + setContentIntent(pendingIntent) + + build() + } + + notificationManager.notify(ID_ALERTS, notification) + } } 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 9318f8b..7d69950 100644 --- a/app/src/main/java/com/chiller3/basicsync/syncthing/SyncthingService.kt +++ b/app/src/main/java/com/chiller3/basicsync/syncthing/SyncthingService.kt @@ -228,6 +228,15 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener notifications.sendOrClearConflictsNotification(conflicts) } } + @GuardedBy("stateLock") + private var syncthingAlerts = 0 + set(count) { + if (field != count) { + field = count + + notifications.sendOrClearAlertsNotification(count) + } + } private val isResumed: Boolean @GuardedBy("stateLock") @@ -577,6 +586,7 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener deviceStateTracker.updateConnectedDevices(0) syncthingConflicts = emptyList() + syncthingAlerts = 0 syncthingApp = null stateChanged() @@ -599,6 +609,13 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener } } + @WorkerThread + override fun onAlertsUpdated(count: Int) { + synchronized(stateLock) { + syncthingAlerts = count + } + } + @WorkerThread override fun onBusyFoldersUpdated(count: Int) { deviceStateTracker.updateBusyFolders(count) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a2a2bc8..5250b29 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -226,6 +226,10 @@ Sync conflicts Alerts for conflicts encountered during syncing + + Syncthing alerts + + Device/folder requests and other alerts reported by Syncthing Syncthing is running @@ -251,6 +255,11 @@ %d sync conflict found %d sync conflicts found + + + %d Syncthing alert + %d Syncthing alerts + Auto mode diff --git a/stbridge/stbridge.go b/stbridge/stbridge.go index e9dacae..dfa63c8 100644 --- a/stbridge/stbridge.go +++ b/stbridge/stbridge.go @@ -12,6 +12,7 @@ import ( "context" "crypto/x509" + "encoding/json" "encoding/pem" "errors" "fmt" @@ -68,14 +69,14 @@ func cleanOldFiles() { for glob, dur := range globs { entries, err := configFs.Glob(glob) if err != nil { - log.Printf("Failed to match glob: %q: %w", glob, err) + log.Printf("Failed to match glob: %q: %v", glob, err) continue } for _, entry := range entries { info, err := configFs.Lstat(entry) if err != nil { - log.Printf("Failed to stat config: %q: %w", entry, err) + log.Printf("Failed to stat config: %q: %v", entry, err) continue } @@ -85,11 +86,11 @@ func cleanOldFiles() { } if err = configFs.RemoveAll(entry); err != nil { - log.Printf("Failed to delete old config: %q: %w", entry, err) + log.Printf("Failed to delete old config: %q: %v", entry, err) continue } - log.Printf("Deleted old config: %q: %w", entry, err) + log.Printf("Deleted old config: %q: %v", entry, err) } } } @@ -226,7 +227,7 @@ func tryPreserveGuiHostPort(c *config.Configuration) error { guiHost, guiPort, err := guiHostPort(c) if err != nil { - log.Printf("Resetting GUI address: %w", err) + log.Printf("Resetting GUI address: %v", err) guiHost = "127.0.0.1" guiPort = defaultPort } @@ -293,6 +294,8 @@ type SyncthingStatusReceiver interface { // paths on Linux. OnConflictsUpdated(paths0Sep string) + OnAlertsUpdated(count int32) + OnBusyFoldersUpdated(count int32) OnConnectedDevicesUpdated(count int32) @@ -310,15 +313,19 @@ func isConflict(name string) bool { return strings.Contains(filepath.Base(name), ".sync-conflict-") } +type conflictsInfo struct { + byFolder map[string]map[string]struct{} + folderPaths map[string]string +} + func dispatchConflicts( - folderConflicts map[string]map[string]struct{}, - folderToPath map[string]string, + conflictsInfo *conflictsInfo, receiver SyncthingStatusReceiver, ) { var paths0Sep strings.Builder - for folder, names := range folderConflicts { - folderPath := folderToPath[folder] + for folder, names := range conflictsInfo.byFolder { + folderPath := conflictsInfo.folderPaths[folder] for name, _ := range names { if paths0Sep.Len() > 0 { @@ -331,6 +338,32 @@ func dispatchConflicts( receiver.OnConflictsUpdated(paths0Sep.String()) } +// The only type of alerts we currently don't track are those associated +// slogutil.ErrorRecorder because it's a pain to deal with more internal types. +type alertsInfo struct { + needsRestart bool + pendingDevices map[string]struct{} + pendingFolders map[string]struct{} + watcherErrors map[string]string +} + +func dispatchAlerts( + alertsInfo *alertsInfo, + receiver SyncthingStatusReceiver, +) { + count := 0 + + if alertsInfo.needsRestart { + count += 1 + } + + count += len(alertsInfo.pendingDevices) + count += len(alertsInfo.pendingFolders) + count += len(alertsInfo.watcherErrors) + + receiver.OnAlertsUpdated(int32(count)) +} + // The scanning states are intentionally excluded because we only want mutating // operations to interrupt the idle timer. var busyEvents = []string{ @@ -368,8 +401,9 @@ func eventLoop( ctx context.Context, stopped chan struct{}, evLogger events.Logger, - folderConflicts map[string]map[string]struct{}, - folderToPath map[string]string, + cfgWrapper config.Wrapper, + conflictsInfo *conflictsInfo, + alertsInfo *alertsInfo, receiver SyncthingStatusReceiver, ) { defer close(stopped) @@ -377,6 +411,9 @@ func eventLoop( sub := evLogger.Subscribe( events.LocalChangeDetected | events.RemoteChangeDetected | + events.PendingDevicesChanged | + events.PendingFoldersChanged | + events.FolderWatchStateChanged | events.StateChanged | events.DeviceConnected | events.DeviceDisconnected | @@ -405,15 +442,84 @@ func eventLoop( } if data["action"] == "deleted" { - delete(folderConflicts[folderID], path) + delete(conflictsInfo.byFolder[folderID], path) } else { - if _, ok := folderConflicts[folderID]; !ok { - folderConflicts[folderID] = map[string]struct{}{} + if _, ok := conflictsInfo.byFolder[folderID]; !ok { + conflictsInfo.byFolder[folderID] = map[string]struct{}{} } - folderConflicts[folderID][path] = struct{}{} + conflictsInfo.byFolder[folderID][path] = struct{}{} } - dispatchConflicts(folderConflicts, folderToPath, receiver) + dispatchConflicts(conflictsInfo, receiver) + + case events.PendingDevicesChanged: + if data, ok := evt.Data.(map[string][]interface{}); ok { + for _, device := range data["added"] { + deviceID := device.(map[string]string)["deviceID"] + + alertsInfo.pendingDevices[deviceID] = struct{}{} + } + } else if data, ok := evt.Data.(map[string]interface{}); ok { + devices := data["removed"].([]map[string]string) + + for _, device := range devices { + deviceID := device["deviceID"] + + delete(alertsInfo.pendingDevices, deviceID) + } + } + + dispatchAlerts(alertsInfo, receiver) + + case events.PendingFoldersChanged: + data := evt.Data.(map[string]interface{}) + + if added, ok := data["added"]; ok { + // This is ugly and slow, but better than using + // unsafe.Pointer to cast to model.updatedPendingFolder. + rawJson, err := json.Marshal(added) + if err != nil { + log.Printf("Failed to serialize JSON: %+v: %v", added, err) + continue + } + + folders := []map[string]interface{}{} + err = json.Unmarshal(rawJson, &folders) + if err != nil { + log.Printf("Failed to deserialize JSON: %q: %v", string(rawJson), err) + continue + } + + for _, folder := range folders { + folderID := folder["folderID"].(string) + + alertsInfo.pendingFolders[folderID] = struct{}{} + } + } + + if removed, ok := data["removed"]; ok { + folders := removed.([]map[string]string) + + for _, folder := range folders { + folderID := folder["folderID"] + + delete(alertsInfo.pendingFolders, folderID) + } + } + + dispatchAlerts(alertsInfo, receiver) + + case events.FolderWatchStateChanged: + data := evt.Data.(map[string]interface{}) + folderID := data["folder"].(string) + + if errMsg, ok := data["to"]; ok { + alertsInfo.watcherErrors[folderID] = errMsg.(string) + } else { + delete(alertsInfo.watcherErrors, folderID) + } + + dispatchAlerts(alertsInfo, receiver) case events.StateChanged: data := evt.Data.(map[string]interface{}) @@ -446,32 +552,38 @@ func eventLoop( case events.ConfigSaved: cfg := evt.Data.(config.Configuration) - clear(folderToPath) + clear(conflictsInfo.folderPaths) for _, folder := range cfg.Folders { // Never fails on Android. - folderToPath[folder.ID], _ = fs.ExpandTilde(folder.Path) + conflictsInfo.folderPaths[folder.ID], _ = fs.ExpandTilde(folder.Path) } - for key := range folderConflicts { - if _, ok := folderToPath[key]; !ok { - delete(folderConflicts, key) + for key := range conflictsInfo.byFolder { + if _, ok := conflictsInfo.folderPaths[key]; !ok { + delete(conflictsInfo.byFolder, key) } } for key := range folderStates { - if _, ok := folderToPath[key]; !ok { + if _, ok := conflictsInfo.folderPaths[key]; !ok { delete(folderStates, key) } } - dispatchConflicts(folderConflicts, folderToPath, receiver) + dispatchConflicts(conflictsInfo, receiver) dispatchBusyFolders(folderStates, receiver) - // Unlike folders, we don't need to remove deleted devices from // devicesConnected. We'll always receive a disconnection event // when connections are closed during deletion. + // A config.Wrapper is needed to determine if a restart is + // required. config.Configuration does not contain enough info. + // We do not need to worry about TOCTOU here because once the + // flag is set, it cannot ever be unset. + alertsInfo.needsRestart = cfgWrapper.RequiresRestart() + dispatchAlerts(alertsInfo, receiver) + default: log.Printf("Unexpected event: %+v", evt) } @@ -490,8 +602,10 @@ func startEventLoop( allLocalFiles allLocalFilesFunc, receiver SyncthingStatusReceiver, ) error { - folderConflicts := map[string]map[string]struct{}{} - folderToPath := map[string]string{} + conflictsInfo := conflictsInfo{ + byFolder: map[string]map[string]struct{}{}, + folderPaths: map[string]string{}, + } // Find the initial set of conflicts from the database before starting the // service. Any newly added or deleted conflicts will be reported by the @@ -504,27 +618,37 @@ func startEventLoop( continue } - if _, ok := folderConflicts[folder.ID]; !ok { - folderConflicts[folder.ID] = map[string]struct{}{} + if _, ok := conflictsInfo.byFolder[folder.ID]; !ok { + conflictsInfo.byFolder[folder.ID] = map[string]struct{}{} } - folderConflicts[folder.ID][dbFile.Name] = struct{}{} + conflictsInfo.byFolder[folder.ID][dbFile.Name] = struct{}{} } if err := errFn(); err != nil { return fmt.Errorf("failed to query database for: %q: %w", folder.ID, err) } // Never fails on Android. - folderToPath[folder.ID], _ = fs.ExpandTilde(folder.Path) + conflictsInfo.folderPaths[folder.ID], _ = fs.ExpandTilde(folder.Path) + } + + dispatchConflicts(&conflictsInfo, receiver) + + alertsInfo := alertsInfo{ + needsRestart: cfg.RequiresRestart(), + pendingDevices: map[string]struct{}{}, + pendingFolders: map[string]struct{}{}, + watcherErrors: map[string]string{}, } - dispatchConflicts(folderConflicts, folderToPath, receiver) + dispatchAlerts(&alertsInfo, receiver) go eventLoop( ctx, stopped, evLogger, - folderConflicts, - folderToPath, + cfg, + &conflictsInfo, + &alertsInfo, receiver, ) @@ -576,7 +700,7 @@ func Run(startup *SyncthingStartupConfig) error { // possible. This intentionally does not use c.ProbeFreePorts() because // that always resets the listen addresses for the sync protocol. if err = tryPreserveGuiHostPort(c); err != nil { - log.Printf("Failed to set GUI listen address: %w", err) + log.Printf("Failed to set GUI listen address: %v", err) } // Try to prevent users from locking themselves out. @@ -720,7 +844,7 @@ func tryAtomicSwap(path1 string, path2 string) error { return err } - log.Printf("Using non-atomic rename because RENAME_EXCHANGE is not supported: %w", err) + log.Printf("Using non-atomic rename because RENAME_EXCHANGE is not supported: %v", err) swapDir, err := os.MkdirTemp(filepath.Dir(path2), "swap") if err != nil { @@ -766,7 +890,7 @@ func ImportConfiguration(fd int, name string, password string) error { } defer func() { if err := os.RemoveAll(tempDir); err != nil { - log.Printf("failed to delete: %q: %w", tempDir, err) + log.Printf("failed to delete: %q: %v", tempDir, err) } }()