feat: rich sync notifications (progress + result + error)

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 02:35:45 +00:00
parent 21d8f0dca2
commit e22db9bced
2 changed files with 91 additions and 16 deletions
@@ -2,12 +2,15 @@ package com.syncflow.worker
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.os.Build import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.hilt.work.HiltWorker import androidx.hilt.work.HiltWorker
import androidx.work.* import androidx.work.*
import com.syncflow.MainActivity
import com.syncflow.R import com.syncflow.R
import com.syncflow.data.db.SyncPairDao import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.toDomain import com.syncflow.data.db.entities.toDomain
@@ -33,44 +36,116 @@ class SyncWorker @AssistedInject constructor(
val pairId = inputData.getLong(KEY_PAIR_ID, -1L) val pairId = inputData.getLong(KEY_PAIR_ID, -1L)
if (pairId == -1L) return Result.failure() if (pairId == -1L) return Result.failure()
setForeground(buildForegroundInfo("Syncing…"))
val pair = syncPairDao.getById(pairId) ?: return Result.failure() val pair = syncPairDao.getById(pairId) ?: return Result.failure()
val account = accountRepository.getAccount(pair.accountId) ?: return Result.failure() val account = accountRepository.getAccount(pair.accountId) ?: return Result.failure()
ensureChannels()
setForeground(buildForegroundInfo(pair.name, "Syncing…"))
return try { return try {
val domainPair = pair.toDomain() val domainPair = pair.toDomain()
val provider = providerFactory.create(account) 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() Result.success()
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "SyncWorker failed for pair $pairId") 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() if (runAttemptCount < 3) Result.retry() else Result.failure()
} }
} }
private fun buildForegroundInfo(progress: String): ForegroundInfo { private fun ensureChannels() {
val channelId = "sync_channel"
val nm = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val nm = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (nm.getNotificationChannel(channelId) == null) { if (nm.getNotificationChannel(CHANNEL_PROGRESS) == null)
nm.createNotificationChannel(NotificationChannel(channelId, "Sync", NotificationManager.IMPORTANCE_LOW)) nm.createNotificationChannel(NotificationChannel(CHANNEL_PROGRESS, "Sync progress", NotificationManager.IMPORTANCE_LOW).apply {
} description = "Shown while a sync is running"
val notification = NotificationCompat.Builder(applicationContext, channelId) })
.setContentTitle("SyncFlow") if (nm.getNotificationChannel(CHANNEL_COMPLETE) == null)
.setContentText(progress) 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) .setSmallIcon(R.drawable.ic_sync)
.setContentIntent(tapIntent)
.setOngoing(true) .setOngoing(true)
.build() .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) ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else { else
ForegroundInfo(NOTIFICATION_ID, notification) 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 { companion object {
const val KEY_PAIR_ID = "pair_id" const val KEY_PAIR_ID = "pair_id"
private const val NOTIFICATION_ID = 1001 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 { fun buildOneTimeRequest(pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean): OneTimeWorkRequest {
val constraints = Constraints.Builder() val constraints = Constraints.Builder()
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.13 VERSION_NAME=1.0.14
VERSION_CODE=14 VERSION_CODE=15