diff --git a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt index f2ef456b..c7aaa6a7 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt @@ -16,7 +16,6 @@ import java.io.IOException import java.net.URLEncoder import java.nio.charset.StandardCharsets.UTF_8 import java.util.concurrent.TimeUnit -import kotlin.random.Random class ApiService(context: Context) { private val repository = Repository.getInstance(context) @@ -139,7 +138,7 @@ class ApiService(context: Context) { val body = response.body.string().trim() if (body.isEmpty()) return emptyList() val notifications = body.lines().mapNotNull { line -> - parser.parse(line, subscriptionId = subscriptionId, notificationId = 0) // No notification when we poll + parser.parse(line, subscriptionId = subscriptionId) // No notification when we poll } Log.d(TAG, "Notifications: $notifications") @@ -170,7 +169,7 @@ class ApiService(context: Context) { val source = response.body.source() while (!source.exhausted()) { val line = source.readUtf8Line() ?: throw Exception("Unexpected response for $url: line is null") - val notification = parser.parseWithTopic(line, notificationId = Random.nextInt(), subscriptionId = 0) // subscriptionId to be set downstream + val notification = parser.parseWithTopic(line, notify = true, subscriptionId = 0) // subscriptionId to be set downstream if (notification != null) { notify(notification.topic, notification.notification) } diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt index 557c971d..95358034 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt @@ -6,6 +6,7 @@ import io.heckel.ntfy.db.Action import io.heckel.ntfy.db.Attachment import io.heckel.ntfy.db.Icon import io.heckel.ntfy.db.Notification +import io.heckel.ntfy.util.deriveNotificationId import io.heckel.ntfy.util.joinTags import io.heckel.ntfy.util.toPriority import java.lang.reflect.Type @@ -13,12 +14,12 @@ import java.lang.reflect.Type class NotificationParser { private val gson = Gson() - fun parse(s: String, subscriptionId: Long = 0, notificationId: Int = 0): Notification? { - val notificationWithTopic = parseWithTopic(s, subscriptionId = subscriptionId, notificationId = notificationId) + fun parse(s: String, subscriptionId: Long = 0, notify: Boolean = false): Notification? { + val notificationWithTopic = parseWithTopic(s, subscriptionId = subscriptionId, notify = notify) return notificationWithTopic?.notification } - fun parseWithTopic(s: String, subscriptionId: Long = 0, notificationId: Int = 0): NotificationWithTopic? { + fun parseWithTopic(s: String, subscriptionId: Long = 0, notify: Boolean = false): NotificationWithTopic? { val message = gson.fromJson(s, Message::class.java) if (message.event != ApiService.EVENT_MESSAGE) { return null @@ -50,6 +51,8 @@ class NotificationParser { } val icon: Icon? = if (message.icon != null && message.icon != "") Icon(url = message.icon) else null val sid = message.sid ?: message.id // Default to id if sid not provided + // Derive notificationId from sid so updates replace the existing Android notification + val notificationId = if (notify) deriveNotificationId(sid) else 0 val notification = Notification( id = message.id, subscriptionId = subscriptionId, diff --git a/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt b/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt index f72b6951..b119f5dd 100644 --- a/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt +++ b/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt @@ -16,7 +16,6 @@ import java.util.* import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference -import kotlin.random.Random /** * Connect to ntfy server via WebSockets. This connection represents a single connection to a server, with @@ -148,7 +147,7 @@ class WsConnection( override fun onMessage(webSocket: WebSocket, text: String) { synchronize("onMessage") { Log.d(TAG, "$shortUrl (gid=$globalId, lid=$id): Received message: $text") - val notificationWithTopic = parser.parseWithTopic(text, subscriptionId = 0, notificationId = Random.nextInt()) + val notificationWithTopic = parser.parseWithTopic(text, subscriptionId = 0, notify = true) if (notificationWithTopic == null) { Log.d(TAG, "$shortUrl (gid=$globalId, lid=$id): Irrelevant or unknown message. Discarding.") return@synchronize diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt index f364e16c..2d2a7228 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -67,13 +67,13 @@ import io.heckel.ntfy.util.randomSubscriptionId import io.heckel.ntfy.util.shortUrl import io.heckel.ntfy.util.topicShortUrl import io.heckel.ntfy.work.DeleteWorker +import io.heckel.ntfy.util.deriveNotificationId import io.heckel.ntfy.work.PollWorker import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.Date import java.util.concurrent.TimeUnit -import kotlin.random.Random import androidx.core.view.size import androidx.core.view.get import androidx.core.net.toUri @@ -709,7 +709,7 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific val newNotifications = repository.onlyNewNotifications(subscription.id, notifications) newNotifications.forEach { notification -> newNotificationsCount++ - val notificationWithId = notification.copy(notificationId = Random.nextInt()) + val notificationWithId = notification.copy(notificationId = deriveNotificationId(notification.sid)) if (repository.addNotification(notificationWithId)) { dispatcher?.dispatch(subscription, notificationWithId) } diff --git a/app/src/main/java/io/heckel/ntfy/util/Util.kt b/app/src/main/java/io/heckel/ntfy/util/Util.kt index 6d7ba987..70e6ee8c 100644 --- a/app/src/main/java/io/heckel/ntfy/util/Util.kt +++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt @@ -503,3 +503,14 @@ fun Button.dangerButton() { fun Long.nullIfZero(): Long? { return if (this == 0L) return null else this } + +/** + * Derives a stable notification ID from a string (typically the sid or id). + * This allows Android to update existing notifications when a new version arrives. + * The result is always positive and never zero (0 means "no notification"). + */ +fun deriveNotificationId(sid: String): Int { + val hash = sid.hashCode() + // Ensure the ID is positive and non-zero + return if (hash == 0 || hash == Int.MIN_VALUE) 1 else kotlin.math.abs(hash) +} diff --git a/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt b/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt index cc431a32..e227745a 100644 --- a/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt @@ -8,9 +8,9 @@ import io.heckel.ntfy.db.Repository import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.NotificationDispatcher import io.heckel.ntfy.util.Log +import io.heckel.ntfy.util.deriveNotificationId import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import kotlin.random.Random class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) { // IMPORTANT: @@ -49,7 +49,7 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, ) val newNotifications = repository .onlyNewNotifications(subscription.id, notifications) - .map { it.copy(notificationId = Random.nextInt()) } + .map { it.copy(notificationId = deriveNotificationId(it.sid)) } newNotifications.forEach { notification -> if (repository.addNotification(notification)) { dispatcher.dispatch(subscription, notification) diff --git a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt index a348a063..47250481 100644 --- a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt +++ b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt @@ -14,6 +14,7 @@ import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.NotificationDispatcher import io.heckel.ntfy.msg.NotificationParser import io.heckel.ntfy.service.SubscriberService +import io.heckel.ntfy.util.deriveNotificationId import io.heckel.ntfy.util.nullIfZero import io.heckel.ntfy.util.toPriority import io.heckel.ntfy.util.topicShortUrl @@ -21,7 +22,6 @@ import io.heckel.ntfy.work.PollWorker import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch -import kotlin.random.Random class FirebaseService : FirebaseMessagingService() { private val repository by lazy { (application as Application).repository } @@ -128,11 +128,12 @@ class FirebaseService : FirebaseMessagingService() { ) } else null val icon: Icon? = if (iconUrl != null && iconUrl != "") Icon(url = iconUrl) else null + val actualSid = sid ?: id val notification = Notification( id = id, subscriptionId = subscription.id, timestamp = timestamp, - sid = sid ?: id, + sid = actualSid, title = title ?: "", message = message, contentType = contentType ?: "", @@ -143,7 +144,7 @@ class FirebaseService : FirebaseMessagingService() { icon = icon, actions = parser.parseActions(actions), attachment = attachment, - notificationId = Random.nextInt(), + notificationId = deriveNotificationId(actualSid), deleted = false ) if (repository.addNotification(notification)) {