Android notification ID

This commit is contained in:
Philipp Heckel 2026-01-05 22:09:15 -05:00
parent a23d3991a1
commit 41f46b667f
7 changed files with 28 additions and 15 deletions

View file

@ -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)
}

View file

@ -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,

View file

@ -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

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)

View file

@ -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)) {