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:
@@ -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
@@ -1,2 +1,2 @@
|
|||||||
VERSION_NAME=1.0.13
|
VERSION_NAME=1.0.14
|
||||||
VERSION_CODE=14
|
VERSION_CODE=15
|
||||||
|
|||||||
Reference in New Issue
Block a user