From e22db9bcede2c3f191d7ca11a049231a279bb3c7 Mon Sep 17 00:00:00 2001 From: Amir Khodak Date: Sun, 24 May 2026 02:35:45 +0000 Subject: [PATCH] feat: rich sync notifications (progress + result + error) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three notification channels: - sync_progress (LOW): foreground notification while syncing, shows pair name - sync_complete (LOW): result after success — "↑X ↓X" or "Up to date" - sync_alerts (DEFAULT): error notification with message on failure Notifications respect per-pair notifyOnComplete / notifyOnError settings. All notifications tap-through to MainActivity. Foreground info now names the pair being synced instead of the generic "Syncing…" text. Bump to 1.0.14 (code 15). Co-Authored-By: Claude Sonnet 4.6 --- .../kotlin/com/syncflow/worker/SyncWorker.kt | 103 +++++++++++++++--- version.properties | 4 +- 2 files changed, 91 insertions(+), 16 deletions(-) diff --git a/app/src/main/kotlin/com/syncflow/worker/SyncWorker.kt b/app/src/main/kotlin/com/syncflow/worker/SyncWorker.kt index 5113f8b..5362772 100644 --- a/app/src/main/kotlin/com/syncflow/worker/SyncWorker.kt +++ b/app/src/main/kotlin/com/syncflow/worker/SyncWorker.kt @@ -2,12 +2,15 @@ package com.syncflow.worker import android.app.NotificationChannel import android.app.NotificationManager +import android.app.PendingIntent import android.content.Context +import android.content.Intent import android.content.pm.ServiceInfo import android.os.Build import androidx.core.app.NotificationCompat import androidx.hilt.work.HiltWorker import androidx.work.* +import com.syncflow.MainActivity import com.syncflow.R import com.syncflow.data.db.SyncPairDao import com.syncflow.data.db.entities.toDomain @@ -33,44 +36,116 @@ class SyncWorker @AssistedInject constructor( val pairId = inputData.getLong(KEY_PAIR_ID, -1L) if (pairId == -1L) return Result.failure() - setForeground(buildForegroundInfo("Syncing…")) - val pair = syncPairDao.getById(pairId) ?: return Result.failure() val account = accountRepository.getAccount(pair.accountId) ?: return Result.failure() + ensureChannels() + setForeground(buildForegroundInfo(pair.name, "Syncing…")) + return try { val domainPair = pair.toDomain() val provider = providerFactory.create(account) - syncEngine.sync(domainPair, provider) + val result = syncEngine.sync(domainPair, provider) + + if (result.error != null && pair.notifyOnError) { + notify( + id = pairId.toInt() + RESULT_ID_OFFSET, + channelId = CHANNEL_ALERTS, + title = "${pair.name} — Sync failed", + text = result.error.message ?: "Unknown error", + priority = NotificationCompat.PRIORITY_DEFAULT, + ) + } else if (pair.notifyOnComplete && result.error == null) { + val summary = buildString { + if (result.uploaded > 0) append("↑${result.uploaded} ") + if (result.downloaded > 0) append("↓${result.downloaded} ") + if (result.deleted > 0) append("🗑${result.deleted} ") + if (result.conflicts > 0) append("⚠${result.conflicts} conflict${if (result.conflicts != 1) "s" else ""}") + }.trim().ifEmpty { "Up to date" } + notify( + id = pairId.toInt() + RESULT_ID_OFFSET, + channelId = CHANNEL_COMPLETE, + title = "${pair.name} — Synced", + text = summary, + priority = NotificationCompat.PRIORITY_LOW, + ) + } + Result.success() } catch (e: Exception) { Timber.e(e, "SyncWorker failed for pair $pairId") + if (pair.notifyOnError) { + notify( + id = pairId.toInt() + RESULT_ID_OFFSET, + channelId = CHANNEL_ALERTS, + title = "${pair.name} — Sync failed", + text = e.message ?: "Unknown error", + priority = NotificationCompat.PRIORITY_DEFAULT, + ) + } if (runAttemptCount < 3) Result.retry() else Result.failure() } } - private fun buildForegroundInfo(progress: String): ForegroundInfo { - val channelId = "sync_channel" + private fun ensureChannels() { val nm = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - if (nm.getNotificationChannel(channelId) == null) { - nm.createNotificationChannel(NotificationChannel(channelId, "Sync", NotificationManager.IMPORTANCE_LOW)) - } - val notification = NotificationCompat.Builder(applicationContext, channelId) - .setContentTitle("SyncFlow") - .setContentText(progress) + if (nm.getNotificationChannel(CHANNEL_PROGRESS) == null) + nm.createNotificationChannel(NotificationChannel(CHANNEL_PROGRESS, "Sync progress", NotificationManager.IMPORTANCE_LOW).apply { + description = "Shown while a sync is running" + }) + if (nm.getNotificationChannel(CHANNEL_COMPLETE) == null) + nm.createNotificationChannel(NotificationChannel(CHANNEL_COMPLETE, "Sync complete", NotificationManager.IMPORTANCE_LOW).apply { + description = "Summary after each successful sync" + }) + if (nm.getNotificationChannel(CHANNEL_ALERTS) == null) + nm.createNotificationChannel(NotificationChannel(CHANNEL_ALERTS, "Sync errors", NotificationManager.IMPORTANCE_DEFAULT).apply { + description = "Alerts for sync failures and conflicts" + }) + } + + private fun buildForegroundInfo(pairName: String, status: String): ForegroundInfo { + val tapIntent = PendingIntent.getActivity( + applicationContext, 0, + Intent(applicationContext, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val notification = NotificationCompat.Builder(applicationContext, CHANNEL_PROGRESS) + .setContentTitle(pairName) + .setContentText(status) .setSmallIcon(R.drawable.ic_sync) + .setContentIntent(tapIntent) .setOngoing(true) .build() - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) - } else { + else ForegroundInfo(NOTIFICATION_ID, notification) - } + } + + private fun notify(id: Int, channelId: String, title: String, text: String, priority: Int) { + val tapIntent = PendingIntent.getActivity( + applicationContext, id, + Intent(applicationContext, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val nm = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + nm.notify(id, NotificationCompat.Builder(applicationContext, channelId) + .setContentTitle(title) + .setContentText(text) + .setSmallIcon(R.drawable.ic_sync) + .setPriority(priority) + .setContentIntent(tapIntent) + .setAutoCancel(true) + .build()) } companion object { const val KEY_PAIR_ID = "pair_id" private const val NOTIFICATION_ID = 1001 + private const val RESULT_ID_OFFSET = 2000 + private const val CHANNEL_PROGRESS = "sync_progress" + private const val CHANNEL_COMPLETE = "sync_complete" + private const val CHANNEL_ALERTS = "sync_alerts" fun buildOneTimeRequest(pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean): OneTimeWorkRequest { val constraints = Constraints.Builder() diff --git a/version.properties b/version.properties index 5cc92d9..a2c04c5 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -VERSION_NAME=1.0.13 -VERSION_CODE=14 +VERSION_NAME=1.0.14 +VERSION_CODE=15