Merge pull request #151 from binwiederhier/303-update-notifications

Update/delete/clear notifications
This commit is contained in:
Philipp C. Heckel 2026-01-15 21:16:36 -05:00 committed by GitHub
commit dd968574a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 783 additions and 120 deletions

View file

@ -0,0 +1,429 @@
{
"formatVersion": 1,
"database": {
"version": 18,
"identityHash": "02663facc6503d5ea7015397d5e8cc94",
"entities": [
{
"tableName": "Subscription",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, `minPriority` INTEGER NOT NULL, `autoDelete` INTEGER NOT NULL, `insistent` INTEGER NOT NULL, `lastNotificationId` TEXT, `icon` TEXT, `upAppId` TEXT, `upConnectorToken` TEXT, `displayName` TEXT, `dedicatedChannels` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "baseUrl",
"columnName": "baseUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "topic",
"columnName": "topic",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instant",
"columnName": "instant",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mutedUntil",
"columnName": "mutedUntil",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minPriority",
"columnName": "minPriority",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "autoDelete",
"columnName": "autoDelete",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "insistent",
"columnName": "insistent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastNotificationId",
"columnName": "lastNotificationId",
"affinity": "TEXT"
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "TEXT"
},
{
"fieldPath": "upAppId",
"columnName": "upAppId",
"affinity": "TEXT"
},
{
"fieldPath": "upConnectorToken",
"columnName": "upConnectorToken",
"affinity": "TEXT"
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT"
},
{
"fieldPath": "dedicatedChannels",
"columnName": "dedicatedChannels",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_Subscription_baseUrl_topic",
"unique": true,
"columnNames": [
"baseUrl",
"topic"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_baseUrl_topic` ON `${TABLE_NAME}` (`baseUrl`, `topic`)"
},
{
"name": "index_Subscription_upConnectorToken",
"unique": true,
"columnNames": [
"upConnectorToken"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_upConnectorToken` ON `${TABLE_NAME}` (`upConnectorToken`)"
}
]
},
{
"tableName": "Notification",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `sequenceId` TEXT NOT NULL, `title` TEXT NOT NULL, `message` TEXT NOT NULL, `contentType` TEXT NOT NULL, `encoding` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `actions` TEXT, `deleted` INTEGER NOT NULL, `icon_url` TEXT, `icon_contentUri` TEXT, `attachment_name` TEXT, `attachment_type` TEXT, `attachment_size` INTEGER, `attachment_expires` INTEGER, `attachment_url` TEXT, `attachment_contentUri` TEXT, `attachment_progress` INTEGER, PRIMARY KEY(`id`, `subscriptionId`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subscriptionId",
"columnName": "subscriptionId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sequenceId",
"columnName": "sequenceId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "message",
"columnName": "message",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "contentType",
"columnName": "contentType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "encoding",
"columnName": "encoding",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationId",
"columnName": "notificationId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "priority",
"columnName": "priority",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "3"
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "click",
"columnName": "click",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "actions",
"columnName": "actions",
"affinity": "TEXT"
},
{
"fieldPath": "deleted",
"columnName": "deleted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "icon.url",
"columnName": "icon_url",
"affinity": "TEXT"
},
{
"fieldPath": "icon.contentUri",
"columnName": "icon_contentUri",
"affinity": "TEXT"
},
{
"fieldPath": "attachment.name",
"columnName": "attachment_name",
"affinity": "TEXT"
},
{
"fieldPath": "attachment.type",
"columnName": "attachment_type",
"affinity": "TEXT"
},
{
"fieldPath": "attachment.size",
"columnName": "attachment_size",
"affinity": "INTEGER"
},
{
"fieldPath": "attachment.expires",
"columnName": "attachment_expires",
"affinity": "INTEGER"
},
{
"fieldPath": "attachment.url",
"columnName": "attachment_url",
"affinity": "TEXT"
},
{
"fieldPath": "attachment.contentUri",
"columnName": "attachment_contentUri",
"affinity": "TEXT"
},
{
"fieldPath": "attachment.progress",
"columnName": "attachment_progress",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"subscriptionId"
]
}
},
{
"tableName": "User",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`baseUrl`))",
"fields": [
{
"fieldPath": "baseUrl",
"columnName": "baseUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "password",
"columnName": "password",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"baseUrl"
]
}
},
{
"tableName": "Log",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `tag` TEXT NOT NULL, `level` INTEGER NOT NULL, `message` TEXT NOT NULL, `exception` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "tag",
"columnName": "tag",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "level",
"columnName": "level",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "message",
"columnName": "message",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "exception",
"columnName": "exception",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "CustomHeader",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `name` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`baseUrl`, `name`))",
"fields": [
{
"fieldPath": "baseUrl",
"columnName": "baseUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"baseUrl",
"name"
]
}
},
{
"tableName": "TrustedCertificate",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `pem` TEXT NOT NULL, PRIMARY KEY(`baseUrl`))",
"fields": [
{
"fieldPath": "baseUrl",
"columnName": "baseUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pem",
"columnName": "pem",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"baseUrl"
]
}
},
{
"tableName": "ClientCertificate",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `p12Base64` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`baseUrl`))",
"fields": [
{
"fieldPath": "baseUrl",
"columnName": "baseUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "p12Base64",
"columnName": "p12Base64",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "password",
"columnName": "password",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"baseUrl"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '02663facc6503d5ea7015397d5e8cc94')"
]
}
}

View file

@ -36,7 +36,6 @@
<!-- Main activity -->
<activity
android:name=".ui.MainActivity"
android:label="@string/app_name"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>

View file

@ -4,6 +4,7 @@ import android.content.Context
import android.net.Uri
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.annotations.SerializedName
import com.google.gson.stream.JsonReader
import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
@ -187,6 +188,7 @@ class Backuper(val context: Context) {
id = n.id,
subscriptionId = n.subscriptionId,
timestamp = n.timestamp,
sequenceId = n.sequenceId ?: n.id,
title = n.title,
message = n.message,
contentType = n.contentType,
@ -343,6 +345,7 @@ class Backuper(val context: Context) {
id = n.id,
subscriptionId = n.subscriptionId,
timestamp = n.timestamp,
sequenceId = n.sequenceId,
title = n.title,
message = n.message,
contentType = n.contentType,
@ -440,6 +443,7 @@ data class Notification(
val id: String,
val subscriptionId: Long,
val timestamp: Long,
@SerializedName("sequence_id") val sequenceId: String?, // Sequence ID for updating notifications
val title: String,
val message: String,
val contentType: String, // "" or "text/markdown" (empty assumes "text/plain")

View file

@ -20,6 +20,7 @@ import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.service.NotAuthorizedException
import io.heckel.ntfy.service.WebSocketNotSupportedException
import io.heckel.ntfy.service.hasCause
@ -144,7 +145,8 @@ data class SubscriptionWithMetadata(
data class Notification(
@ColumnInfo(name = "id") val id: String,
@ColumnInfo(name = "subscriptionId") val subscriptionId: Long,
@ColumnInfo(name = "timestamp") val timestamp: Long, // Unix timestamp
@ColumnInfo(name = "timestamp") val timestamp: Long, // Unix timestamp in seconds
@ColumnInfo(name = "sequenceId") val sequenceId: String, // Sequence ID for updating notifications
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "message") val message: String,
@ColumnInfo(name = "contentType") val contentType: String, // "" or "text/markdown" (empty assume text/plain)
@ -157,7 +159,30 @@ data class Notification(
@ColumnInfo(name = "actions") val actions: List<Action>?,
@Embedded(prefix = "attachment_") val attachment: Attachment?,
@ColumnInfo(name = "deleted") val deleted: Boolean,
)
@Ignore val event: String = ApiService.EVENT_MESSAGE, // In-memory event type (message, message_delete, message_clear)
) {
constructor(
id: String,
subscriptionId: Long,
timestamp: Long,
sequenceId: String,
title: String,
message: String,
contentType: String,
encoding: String,
notificationId: Int,
priority: Int,
tags: String,
click: String,
icon: Icon?,
actions: List<Action>?,
attachment: Attachment?,
deleted: Boolean
) : this(
id, subscriptionId, timestamp, sequenceId, title, message, contentType, encoding,
notificationId, priority, tags, click, icon, actions, attachment, deleted, event = ApiService.EVENT_MESSAGE
)
}
fun Notification.isMarkdown(): Boolean {
return contentType == "text/markdown"
@ -272,7 +297,7 @@ data class LogEntry(
}
@androidx.room.Database(
version = 17,
version = 18,
entities = [
Subscription::class,
Notification::class,
@ -317,6 +342,7 @@ abstract class Database : RoomDatabase() {
.addMigrations(MIGRATION_14_15)
.addMigrations(MIGRATION_15_16)
.addMigrations(MIGRATION_16_17)
.addMigrations(MIGRATION_17_18)
.fallbackToDestructiveMigration(true)
.build()
this.instance = instance
@ -450,6 +476,13 @@ abstract class Database : RoomDatabase() {
db.execSQL("UPDATE Notification SET icon_contentUri = NULL WHERE icon_url IS NULL AND icon_contentUri IS NOT NULL")
}
}
private val MIGRATION_17_18 = object : Migration(17, 18) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE Notification ADD COLUMN sequenceId TEXT NOT NULL DEFAULT ''")
db.execSQL("UPDATE Notification SET sequenceId = id WHERE sequenceId = ''")
}
}
}
}
@ -541,9 +574,6 @@ interface NotificationDao {
@Query("SELECT * FROM notification WHERE subscriptionId = :subscriptionId AND deleted != 1 ORDER BY timestamp DESC")
fun listFlow(subscriptionId: Long): Flow<List<Notification>>
@Query("SELECT id FROM notification WHERE subscriptionId = :subscriptionId") // Includes deleted
fun listIds(subscriptionId: Long): List<String>
@Query("SELECT * FROM notification WHERE deleted = 1 AND attachment_contentUri <> ''")
fun listDeletedWithAttachments(): List<Notification>
@ -563,11 +593,17 @@ interface NotificationDao {
fun get(notificationId: String): Notification?
@Query("UPDATE notification SET notificationId = 0 WHERE subscriptionId = :subscriptionId")
fun clearAllNotificationIds(subscriptionId: Long)
fun markAllAsRead(subscriptionId: Long)
@Query("UPDATE notification SET notificationId = 0 WHERE subscriptionId = :subscriptionId AND sequenceId = :sequenceId")
fun markAsReadBySequenceId(subscriptionId: Long, sequenceId: String)
@Query("UPDATE notification SET deleted = 1 WHERE id = :notificationId")
fun markAsDeleted(notificationId: String)
@Query("UPDATE notification SET deleted = 1 WHERE subscriptionId = :subscriptionId AND sequenceId = :sequenceId")
fun markAsDeletedBySequenceId(subscriptionId: Long, sequenceId: String)
@Query("UPDATE notification SET deleted = 1 WHERE subscriptionId = :subscriptionId")
fun markAllAsDeleted(subscriptionId: Long)

View file

@ -12,6 +12,7 @@ import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.asLiveData
import androidx.lifecycle.map
import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.validUrl
import java.util.concurrent.ConcurrentHashMap
@ -117,26 +118,21 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database)
return notificationDao.listFlow(subscriptionId).asLiveData()
}
fun clearAllNotificationIds(subscriptionId: Long) {
return notificationDao.clearAllNotificationIds(subscriptionId)
}
fun getNotification(notificationId: String): Notification? {
return notificationDao.get(notificationId)
}
fun onlyNewNotifications(subscriptionId: Long, notifications: List<Notification>): List<Notification> {
val existingIds = notificationDao.listIds(subscriptionId)
return notifications.filterNot { existingIds.contains(it.id) }
}
@Suppress("RedundantSuspendModifier")
@WorkerThread
suspend fun addNotification(notification: Notification): Boolean {
val maybeExistingNotification = notificationDao.get(notification.id)
if (maybeExistingNotification != null) {
if (maybeExistingNotification != null || notification.event != ApiService.EVENT_MESSAGE) {
return false
}
// Mark old notifications with the same sequence ID as deleted (this is an update to an existing sequence)
if (notification.sequenceId.isNotEmpty()) {
notificationDao.markAsDeletedBySequenceId(notification.subscriptionId, notification.sequenceId)
}
subscriptionDao.updateLastNotificationId(notification.subscriptionId, notification.id)
notificationDao.add(notification)
return true
@ -154,10 +150,22 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database)
notificationDao.markAsDeleted(notificationId)
}
fun markAsDeletedBySequenceId(subscriptionId: Long, sequenceId: String) {
notificationDao.markAsDeletedBySequenceId(subscriptionId, sequenceId)
}
fun markAllAsDeleted(subscriptionId: Long) {
notificationDao.markAllAsDeleted(subscriptionId)
}
fun markAllAsRead(subscriptionId: Long) {
notificationDao.markAllAsRead(subscriptionId)
}
fun markAsReadBySequenceId(subscriptionId: Long, sequenceId: String) {
notificationDao.markAsReadBySequenceId(subscriptionId, sequenceId)
}
fun markAsDeletedIfOlderThan(subscriptionId: Long, olderThanTimestamp: Long) {
notificationDao.markAsDeletedIfOlderThan(subscriptionId, olderThanTimestamp)
}

View file

@ -4,6 +4,7 @@ import android.content.Context
import com.google.gson.Gson
import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.Subscription
import io.heckel.ntfy.db.User
import io.heckel.ntfy.service.NotAuthorizedException
import io.heckel.ntfy.util.ALL_PRIORITIES
@ -20,7 +21,6 @@ import okhttp3.RequestBody.Companion.toRequestBody
import okio.BufferedSource
import java.io.IOException
import java.net.URLEncoder
import kotlin.random.Random
class ApiService(private val context: Context) {
private val repository = Repository.getInstance(context)
@ -114,11 +114,15 @@ class ApiService(private val context: Context) {
}
}
suspend fun poll(subscriptionId: Long, baseUrl: String, topic: String, user: User?, since: String? = null): List<Notification> {
val sinceVal = since ?: "all"
suspend fun poll(subscription: Subscription): List<Notification> {
val subscriptionId = subscription.id
val baseUrl = subscription.baseUrl
val topic = subscription.topic
val sinceVal = subscription.lastNotificationId ?: "all"
val url = topicUrlJsonPoll(baseUrl, topic, sinceVal)
Log.d(TAG, "Polling topic $url")
val user = repository.getUser(baseUrl)
val customHeaders = repository.getCustomHeaders(baseUrl)
val request = HttpUtil.requestBuilder(url, user, customHeaders).build()
HttpUtil.defaultClient(context, baseUrl).newCall(request).execute().use { response ->
@ -128,7 +132,7 @@ class ApiService(private val 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)
}
Log.d(TAG, "Notifications: $notifications")
@ -156,7 +160,7 @@ class ApiService(private val context: Context) {
if (code == 401 || code == 403) {
throw NotAuthorizedException(code, message)
}
throw IOException("Unexpected response $code when subscribing to $url")
throw IOException("Unexpected response $code when subscribing")
}
return Pair(call, response.body.source())
}
@ -198,6 +202,8 @@ class ApiService(private val context: Context) {
// These constants have corresponding values in the server codebase!
const val CONTROL_TOPIC = "~control"
const val EVENT_MESSAGE = "message"
const val EVENT_MESSAGE_DELETE = "message_delete"
const val EVENT_MESSAGE_CLEAR = "message_clear"
const val EVENT_KEEPALIVE = "keepalive"
const val EVENT_POLL_REQUEST = "poll_request"
}

View file

@ -177,8 +177,7 @@ class DownloadAttachmentWorker(private val context: Context, params: WorkerParam
}
private fun shouldAbortDownload(): Boolean {
val maxAutoDownloadSize = repository.getAutoDownloadMaxSize()
when (maxAutoDownloadSize) {
when (val maxAutoDownloadSize = repository.getAutoDownloadMaxSize()) {
Repository.AUTO_DOWNLOAD_NEVER -> return true
Repository.AUTO_DOWNLOAD_ALWAYS -> return false
else -> {

View file

@ -9,6 +9,7 @@ import com.google.gson.annotations.SerializedName
data class Message(
val id: String,
val time: Long,
@SerializedName("sequence_id") val sequenceId: String?, // Sequence ID for updating notifications
val event: String,
val topic: String,
val priority: Int?,
@ -17,7 +18,7 @@ data class Message(
val icon: String?,
val actions: List<MessageAction>?,
val title: String?,
val message: String,
val message: String?,
@SerializedName("content_type") val contentType: String?,
val encoding: String?,
val attachment: MessageAttachment?,

View file

@ -4,8 +4,8 @@ import android.content.Context
import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.Subscription
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.up.Distributor
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.decodeBytesMessage
import io.heckel.ntfy.util.safeLet
@ -25,13 +25,16 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
fun dispatch(subscription: Subscription, notification: Notification) {
Log.d(TAG, "Dispatching $notification for subscription $subscription")
val cancel = shouldCancel(notification)
val muted = getMuted(subscription)
val notify = shouldNotify(subscription, notification, muted)
val broadcast = shouldBroadcast(subscription)
val distribute = shouldDistribute(subscription)
val broadcast = shouldBroadcast(subscription, notification)
val distribute = shouldDistribute(subscription, notification)
val downloadAttachment = shouldDownloadAttachment(notification)
val downloadIcon = shouldDownloadIcon(notification)
if (notify) {
if (cancel) {
notifier.cancel(notification.notificationId)
} else if (notify) {
notifier.display(subscription, notification)
}
if (broadcast) {
@ -52,7 +55,7 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
}
private fun shouldDownloadAttachment(notification: Notification): Boolean {
if (notification.attachment == null) {
if (notification.attachment == null || notification.event != ApiService.EVENT_MESSAGE) {
return false
}
val attachment = notification.attachment
@ -72,11 +75,15 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
}
}
private fun shouldDownloadIcon(notification: Notification): Boolean {
return notification.icon?.hasValidUrl() == true
return notification.icon?.hasValidUrl() == true && notification.event == ApiService.EVENT_MESSAGE
}
private fun shouldCancel(notification: Notification): Boolean {
return notification.event == ApiService.EVENT_MESSAGE_CLEAR || notification.event == ApiService.EVENT_MESSAGE_DELETE
}
private fun shouldNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean {
if (subscription.upAppId != null) {
if (subscription.upAppId != null || notification.event != ApiService.EVENT_MESSAGE) {
return false
}
val priority = if (notification.priority > 0) notification.priority else 3
@ -88,15 +95,15 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
return !detailsVisible && !muted
}
private fun shouldBroadcast(subscription: Subscription): Boolean {
if (subscription.upAppId != null) { // Never broadcast for UnifiedPush subscriptions
private fun shouldBroadcast(subscription: Subscription, notification: Notification): Boolean {
if (subscription.upAppId != null || notification.event != ApiService.EVENT_MESSAGE) { // Never broadcast for UnifiedPush subscriptions
return false
}
return repository.getBroadcastEnabled()
}
private fun shouldDistribute(subscription: Subscription): Boolean {
return subscription.upAppId != null // Only distribute for UnifiedPush subscriptions
private fun shouldDistribute(subscription: Subscription, notification: Notification): Boolean {
return subscription.upAppId != null && notification.event == ApiService.EVENT_MESSAGE // Only distribute for UnifiedPush subscriptions
}
private fun getMuted(subscription: Subscription): Boolean {

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,14 +14,17 @@ 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): Notification? {
val notificationWithTopic = parseWithTopic(s, subscriptionId = subscriptionId)
return notificationWithTopic?.notification
}
fun parseWithTopic(s: String, subscriptionId: Long = 0, notificationId: Int = 0): NotificationWithTopic? {
fun parseWithTopic(s: String, subscriptionId: Long = 0): NotificationWithTopic? {
val message = gson.fromJson(s, Message::class.java)
if (message.event != ApiService.EVENT_MESSAGE) {
val validEvent = message.event == ApiService.EVENT_MESSAGE ||
message.event == ApiService.EVENT_MESSAGE_DELETE ||
message.event == ApiService.EVENT_MESSAGE_CLEAR
if (!validEvent) {
return null
}
val attachment = if (message.attachment?.url != null) {
@ -32,8 +36,7 @@ class NotificationParser {
url = message.attachment.url,
)
} else null
val actions = if (message.actions != null) {
message.actions.map { a ->
val actions = message.actions?.map { a ->
Action(
id = a.id,
action = a.action,
@ -49,14 +52,15 @@ class NotificationParser {
error = null
)
}
} else null
val icon: Icon? = if (message.icon != null && message.icon != "") Icon(url = message.icon) else null
val sequenceId = message.sequenceId ?: message.id // Default to id if sequenceId not provided
val notification = Notification(
id = message.id,
subscriptionId = subscriptionId,
timestamp = message.time,
sequenceId = sequenceId,
title = message.title ?: "",
message = message.message,
message = message.message ?: "",
contentType = message.contentType ?: "",
encoding = message.encoding ?: "",
priority = toPriority(message.priority),
@ -65,8 +69,9 @@ class NotificationParser {
icon = icon,
actions = actions,
attachment = attachment,
notificationId = notificationId,
deleted = false
notificationId = deriveNotificationId(sequenceId),
deleted = false,
event = message.event
)
return NotificationWithTopic(message.topic, notification)
}

View file

@ -0,0 +1,77 @@
package io.heckel.ntfy.msg
import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.Subscription
import io.heckel.ntfy.util.Log
/**
* Polls the server for notifications and updates the repository.
* Groups notifications by sequenceId and only keeps the latest for each sequence.
* Deletes sequences where the latest notification is marked as deleted.
*/
class Poller(
private val api: ApiService,
private val repository: Repository
) {
/**
* Polls for notifications and updates the repository.
* Returns the list of new notifications that were added.
*
* @param subscription The subscription to poll
*/
suspend fun poll(subscription: Subscription): List<Notification> {
val notifications = api.poll(subscription)
return processNotifications(subscription.id, notifications)
}
/**
* Processes a list of notifications: groups by sequenceId, deletes deleted sequences,
* and adds only non-deleted latest notifications.
* Returns the list of notifications that were added.
*/
private suspend fun processNotifications(
subscriptionId: Long,
notifications: List<Notification>
): List<Notification> {
// Group by sequenceId and only keep the latest notification for each sequence
val latestBySequenceId = notifications
.groupBy { it.sequenceId.ifEmpty { it.id } }
.mapValues { (_, notifs) -> notifs.maxByOrNull { it.timestamp } }
.values
.filterNotNull()
// Handle delete and read events
latestBySequenceId
.filter { it.event == ApiService.EVENT_MESSAGE_CLEAR || it.event == ApiService.EVENT_MESSAGE_DELETE }
.forEach { notification ->
val sequenceId = notification.sequenceId.ifEmpty { notification.id }
when (notification.event) {
ApiService.EVENT_MESSAGE_DELETE -> {
Log.d(TAG, "Deleting notifications with sequenceId $sequenceId")
repository.markAsDeletedBySequenceId(subscriptionId, sequenceId)
}
ApiService.EVENT_MESSAGE_CLEAR -> {
Log.d(TAG, "Marking notifications as read with sequenceId $sequenceId")
repository.markAsReadBySequenceId(subscriptionId, sequenceId)
}
}
}
// Add only regular message notifications
val notificationsToAdd = latestBySequenceId
.filter { it.event == ApiService.EVENT_MESSAGE }
val addedNotifications = mutableListOf<Notification>()
notificationsToAdd.forEach { notification ->
if (repository.addNotification(notification)) {
addedNotifications.add(notification)
}
}
return addedNotifications
}
companion object {
private const val TAG = "NtfyPoller"
}
}

View file

@ -16,7 +16,6 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import okhttp3.Call
import kotlin.random.Random
class JsonConnection(
private val connectionId: ConnectionId,
@ -58,7 +57,7 @@ class JsonConnection(
// Blocking read loop: reads JSON lines until connection closes or is cancelled
while (isActive && serviceActive() && !source.exhausted()) {
val line = source.readUtf8Line() ?: break
val notificationWithTopic = parser.parseWithTopic(line, notificationId = Random.nextInt(), subscriptionId = 0)
val notificationWithTopic = parser.parseWithTopic(line, subscriptionId = 0)
if (notificationWithTopic != null) {
since = notificationWithTopic.notification.id
val topic = notificationWithTopic.topic

View file

@ -1,6 +1,12 @@
package io.heckel.ntfy.service
import android.app.*
import android.app.AlarmManager
import android.app.ForegroundServiceStartNotAllowedException
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
@ -319,16 +325,34 @@ class SubscriberService : Service() {
val url = topicUrl(subscription.baseUrl, subscription.topic)
Log.d(TAG, "[$url] Received notification: $notification")
GlobalScope.launch(Dispatchers.IO) {
if (repository.addNotification(notification)) {
Log.d(TAG, "[$url] Dispatching notification $notification")
// This logic is (partially) duplicated in
// - Android: SubscriberService::onNotificationReceived()
// - Android: FirebaseService::onMessageReceived()
// - Web app: hooks.js:handleNotification()
// - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ...
when (notification.event) {
ApiService.EVENT_MESSAGE_CLEAR -> {
if (notification.sequenceId.isNotEmpty()) {
repository.markAsReadBySequenceId(subscription.id, notification.sequenceId)
}
dispatcher.dispatch(subscription, notification)
}
wakeLock?.let {
if (it.isHeld) {
it.release()
ApiService.EVENT_MESSAGE_DELETE -> {
if (notification.sequenceId.isNotEmpty()) {
repository.markAsDeletedBySequenceId(subscription.id, notification.sequenceId)
}
dispatcher.dispatch(subscription, notification)
}
ApiService.EVENT_MESSAGE -> {
val added = repository.addNotification(notification)
if (added) {
dispatcher.dispatch(subscription, notification)
}
}
}
wakeLock?.let { if (it.isHeld) { it.release() } }
}
}
private fun createNotificationChannel(): NotificationManager {

View file

@ -21,7 +21,6 @@ import java.net.ProtocolException
import java.util.Calendar
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)
if (notificationWithTopic == null) {
Log.d(TAG, "$shortUrl (gid=$globalId, lid=$id): Irrelevant or unknown message. Discarding.")
return@synchronize

View file

@ -36,6 +36,7 @@ import io.heckel.ntfy.db.Subscription
import io.heckel.ntfy.firebase.FirebaseMessenger
import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.NotificationService
import io.heckel.ntfy.msg.Poller
import io.heckel.ntfy.service.SubscriberServiceManager
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.copyToClipboard
@ -67,6 +68,7 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
}
private val repository by lazy { (application as Application).repository }
private val api by lazy { ApiService(this) }
private val poller by lazy { Poller(api, repository) }
private val messenger = FirebaseMessenger()
private var notifier: NotificationService? = null // Context-dependent
private var appBaseUrl: String? = null // Context-dependent
@ -230,9 +232,7 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
// Fetch cached messages
try {
val user = repository.getUser(subscription.baseUrl) // May be null
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic, user)
notifications.forEach { notification -> repository.addNotification(notification) }
poller.poll(subscription)
} catch (e: Exception) {
Log.e(TAG, "Unable to fetch notifications: ${e.message}", e)
}
@ -337,6 +337,7 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
val snackbar = Snackbar.make(mainList, R.string.detail_item_snack_deleted, Snackbar.LENGTH_SHORT)
snackbar.setAction(R.string.detail_item_snack_undo) {
lifecycleScope.launch(Dispatchers.IO) {
// Note: undo only restores the latest notification, not the entire sequence
repository.undeleteNotification(notification.id)
}
}
@ -524,7 +525,7 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
GlobalScope.launch(Dispatchers.IO) {
// Note: This is here and not in onDestroy/onStop, because we want to clear notifications as early
// as possible, so that we don't see the "new" bubble in the main list anymore.
repository.clearAllNotificationIds(subscriptionId)
repository.markAllAsRead(subscriptionId)
}
Log.d(TAG, "onPause hook: Marking subscription $subscriptionId as 'not open'")
repository.detailViewSubscriptionId.set(0) // Mark as closed
@ -721,15 +722,12 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
lifecycleScope.launch(Dispatchers.IO) {
try {
val subscription = repository.getSubscription(subscriptionId) ?: return@launch
val user = repository.getUser(subscription.baseUrl) // May be null
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic, user, subscription.lastNotificationId)
val newNotifications = repository.onlyNewNotifications(subscriptionId, notifications)
val newNotifications = poller.poll(subscription)
val toastMessage = if (newNotifications.isEmpty()) {
getString(R.string.refresh_message_no_results)
} else {
getString(R.string.refresh_message_result, newNotifications.size)
}
newNotifications.forEach { notification -> repository.addNotification(notification) }
runOnUiThread {
Toast.makeText(this@DetailActivity, toastMessage, Toast.LENGTH_LONG).show()
mainListContainer.isRefreshing = false

View file

@ -54,6 +54,7 @@ import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.DownloadManager
import io.heckel.ntfy.msg.DownloadType
import io.heckel.ntfy.msg.NotificationDispatcher
import io.heckel.ntfy.msg.Poller
import io.heckel.ntfy.service.SubscriberService
import io.heckel.ntfy.service.SubscriberServiceManager
import io.heckel.ntfy.util.Log
@ -73,7 +74,6 @@ 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
@ -84,6 +84,7 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific
}
private val repository by lazy { (application as Application).repository }
private val api by lazy { ApiService(this) }
private val poller by lazy { Poller(api, repository) }
private val messenger = FirebaseMessenger()
// UI elements
@ -690,10 +691,8 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific
// Fetch cached messages
lifecycleScope.launch(Dispatchers.IO) {
try {
val user = repository.getUser(subscription.baseUrl) // May be null
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic, user)
val notifications = poller.poll(subscription)
notifications.forEach { notification ->
repository.addNotification(notification)
if (notification.icon != null) {
DownloadManager.enqueue(this@MainActivity, notification.id, userAction = false, DownloadType.ICON)
}
@ -730,17 +729,12 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific
var errorMessage = "" // First error
var newNotificationsCount = 0
repository.getSubscriptions().forEach { subscription ->
Log.d(TAG, "subscription: $subscription")
Log.d(TAG, "Polling subscription: $subscription")
try {
val user = repository.getUser(subscription.baseUrl) // May be null
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic, user, subscription.lastNotificationId)
val newNotifications = repository.onlyNewNotifications(subscription.id, notifications)
val newNotifications = poller.poll(subscription)
newNotificationsCount += newNotifications.size
newNotifications.forEach { notification ->
newNotificationsCount++
val notificationWithId = notification.copy(notificationId = Random.nextInt())
if (repository.addNotification(notificationWithId)) {
dispatcher?.dispatch(subscription, notificationWithId)
}
dispatcher?.dispatch(subscription, notification)
}
} catch (e: Exception) {
val topic = displayName(appBaseUrl, subscription)
@ -789,7 +783,7 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific
private fun handleActionModeClick(subscription: Subscription) {
adapter.toggleSelection(subscription.id)
if (adapter.selected.size == 0) {
if (adapter.selected.isEmpty()) {
finishActionMode()
} else {
actionMode!!.title = adapter.selected.size.toString()

View file

@ -511,3 +511,13 @@ 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 sequenceId 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(sequenceId: String): Int {
val hash = sequenceId.hashCode()
return if (hash == 0 || hash == Int.MIN_VALUE) 1 else abs(hash)
}

View file

@ -7,10 +7,10 @@ import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.NotificationDispatcher
import io.heckel.ntfy.msg.Poller
import io.heckel.ntfy.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.random.Random
class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
// IMPORTANT:
@ -27,6 +27,7 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
val repository = Repository.getInstance(applicationContext)
val dispatcher = NotificationDispatcher(applicationContext, repository)
val api = ApiService(applicationContext)
val poller = Poller(api, repository)
val baseUrl = inputData.getString(INPUT_DATA_BASE_URL)
val topic = inputData.getString(INPUT_DATA_TOPIC)
@ -39,22 +40,10 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
subscriptions.forEach{ subscription ->
try {
val user = repository.getUser(subscription.baseUrl)
val notifications = api.poll(
subscriptionId = subscription.id,
baseUrl = subscription.baseUrl,
topic = subscription.topic,
user = user,
since = subscription.lastNotificationId
)
val newNotifications = repository
.onlyNewNotifications(subscription.id, notifications)
.map { it.copy(notificationId = Random.nextInt()) }
val newNotifications = poller.poll(subscription)
newNotifications.forEach { notification ->
if (repository.addNotification(notification)) {
dispatcher.dispatch(subscription, notification)
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed checking messages: ${e.message}", e)
}

View file

@ -447,4 +447,19 @@
<string name="settings_advanced_certificates_error_invalid_p12">Ungültige PKCS#12-Datei</string>
<string name="trusted_certificate_dialog_title">Zertifikatsdetails</string>
<string name="trusted_certificate_dialog_title_unknown">Sicherheitswarnung</string>
<string name="trusted_certificate_dialog_title_add">Füge vertrauenswürdiges Zertifikat hinzu</string>
<string name="trusted_certificate_dialog_security_description">Dieses Zertifikat ist nicht vertrauenswürdig. Angreifer könnten deine Daten stehlen. Verwende dieses Zertifikat nur, wenn du weißt, warum es nicht vertrauenswürdig ist.</string>
<string name="trusted_certificate_dialog_description_add">Du hast ein Zertifikat ausgewählt. Überprüfe die untenstehenden Details, bevor du es hinzufügst.</string>
<string name="trusted_certificate_dialog_security_title">Deine Verbindung ist nicht privat</string>
<string name="trusted_certificate_dialog_expired_warning">Achtung: Dieses Zertifikat ist abgelaufen.</string>
<string name="trusted_certificate_dialog_not_yet_valid_warning">Achtung: Dieses Zertifikat ist noch nicht gültig.</string>
<string name="trusted_certificate_dialog_error_invalid_url">Unglültige URL</string>
<string name="trusted_certificate_dialog_error_parse">Das Zertifikat konnte nicht geladen werden: %1$s</string>
<string name="trusted_certificate_dialog_button_trust">Vertrauen</string>
<string name="client_certificate_dialog_title">Client-Zertifikat</string>
<string name="client_certificate_dialog_title_add">Füge Client-Zertifikat hinzu</string>
<string name="client_certificate_dialog_password_hint">Passwort</string>
<string name="client_certificate_dialog_error_wrong_password">Falsches Passwort oder ungültige PKCS#12-Datei</string>
<string name="client_certificate_dialog_error_invalid_p12_password">Ungültiges Passwort oder fehlerhafte PKCS#12-Datei</string>
<string name="client_certificate_dialog_error_invalid_url">Ungültige Service-URL</string>
</resources>

View file

@ -406,4 +406,7 @@
<string name="settings_advanced_certificates_title">Administrar certificados</string>
<string name="settings_advanced_certificates_summary">Añadir certificados a la lista de confiados y administrar certificados de cliente para mTLS</string>
<string name="settings_advanced_certificates_trusted_header">Certificados confiados</string>
<string name="common_service_url_placeholder">ej. https://ntfy.example.com</string>
<string name="common_certificate_subject">Subject</string>
<string name="main_menu_connection_error">Error de conexión</string>
</resources>

View file

@ -1,7 +1,12 @@
package io.heckel.ntfy.firebase
import android.content.Intent
import androidx.work.*
import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import androidx.work.workDataOf
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import io.heckel.ntfy.R
@ -9,11 +14,13 @@ import io.heckel.ntfy.app.Application
import io.heckel.ntfy.db.Attachment
import io.heckel.ntfy.db.Icon
import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.NotificationDispatcher
import io.heckel.ntfy.msg.NotificationParser
import io.heckel.ntfy.msg.NotificationService
import io.heckel.ntfy.service.SubscriberService
import io.heckel.ntfy.util.Log
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 +28,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 }
@ -41,9 +47,17 @@ class FirebaseService : FirebaseMessagingService() {
}
// Dispatch event
//
// This logic is (partially) duplicated in
// - Android: SubscriberService::onNotificationReceived()
// - Android: FirebaseService::onMessageReceived()
// - Web app: hooks.js:handleNotification()
// - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ...
val data = remoteMessage.data
when (data["event"]) {
ApiService.EVENT_MESSAGE -> handleMessage(remoteMessage)
ApiService.EVENT_MESSAGE_DELETE -> handleMessageDelete(remoteMessage)
ApiService.EVENT_MESSAGE_CLEAR -> handleMessageClear(remoteMessage)
ApiService.EVENT_KEEPALIVE -> handleKeepalive(remoteMessage)
ApiService.EVENT_POLL_REQUEST -> handlePollRequest(remoteMessage)
else -> Log.d(TAG, "Discarding unexpected message (2): from=${remoteMessage.from}, data=${data}")
@ -80,6 +94,46 @@ class FirebaseService : FirebaseMessagingService() {
workManager.enqueueUniqueWork(workName, ExistingWorkPolicy.REPLACE, workRequest)
}
private fun handleMessageDelete(remoteMessage: RemoteMessage) {
val data = remoteMessage.data
val topic = data["topic"] ?: return
val sequenceId = data["sequence_id"] ?: return
Log.d(TAG, "Received message_delete: from=${remoteMessage.from}, topic=$topic, sequenceId=$sequenceId")
CoroutineScope(job).launch {
val baseUrl = getString(R.string.app_base_url)
val subscription = repository.getSubscription(baseUrl, topic) ?: return@launch
// Mark all notifications with this sequenceId as deleted
repository.markAsDeletedBySequenceId(subscription.id, sequenceId)
// Cancel the Android notification
val notificationId = deriveNotificationId(sequenceId)
val notifier = NotificationService(this@FirebaseService)
notifier.cancel(notificationId)
}
}
private fun handleMessageClear(remoteMessage: RemoteMessage) {
val data = remoteMessage.data
val topic = data["topic"] ?: return
val sequenceId = data["sequence_id"] ?: return
Log.d(TAG, "Received message_clear: from=${remoteMessage.from}, topic=$topic, sequenceId=$sequenceId")
CoroutineScope(job).launch {
val baseUrl = getString(R.string.app_base_url)
val subscription = repository.getSubscription(baseUrl, topic) ?: return@launch
// Mark all notifications with this sequenceId as read
repository.markAsReadBySequenceId(subscription.id, sequenceId)
// Cancel the Android notification
val notificationId = deriveNotificationId(sequenceId)
val notifier = NotificationService(this@FirebaseService)
notifier.cancel(notificationId)
}
}
private fun handleMessage(remoteMessage: RemoteMessage) {
val data = remoteMessage.data
val id = data["id"]
@ -99,6 +153,7 @@ class FirebaseService : FirebaseMessagingService() {
val attachmentSize = data["attachment_size"]?.toLongOrNull()?.nullIfZero()
val attachmentExpires = data["attachment_expires"]?.toLongOrNull()?.nullIfZero()
val attachmentUrl = data["attachment_url"]
val sequenceId = data["sequence_id"]
val truncated = (data["truncated"] ?: "") == "1"
if (id == null || topic == null || message == null || timestamp == null) {
Log.d(TAG, "Discarding unexpected message: from=${remoteMessage.from}, fcmprio=${remoteMessage.priority}, fcmprio_orig=${remoteMessage.originalPriority}, data=${data}")
@ -127,10 +182,12 @@ class FirebaseService : FirebaseMessagingService() {
)
} else null
val icon: Icon? = if (iconUrl != null && iconUrl != "") Icon(url = iconUrl) else null
val actualSequenceId = sequenceId ?: id
val notification = Notification(
id = id,
subscriptionId = subscription.id,
timestamp = timestamp,
sequenceId = actualSequenceId,
title = title ?: "",
message = message,
contentType = contentType ?: "",
@ -141,10 +198,13 @@ class FirebaseService : FirebaseMessagingService() {
icon = icon,
actions = parser.parseActions(actions),
attachment = attachment,
notificationId = Random.nextInt(),
deleted = false
notificationId = deriveNotificationId(actualSequenceId),
deleted = false,
event = ApiService.EVENT_MESSAGE
)
if (repository.addNotification(notification)) {
val added = repository.addNotification(notification)
if (added) {
Log.d(TAG, "Dispatching notification: from=${remoteMessage.from}, fcmprio=${remoteMessage.priority}, fcmprio_orig=${remoteMessage.originalPriority}, data=${data}")
dispatcher.dispatch(subscription, notification)
}

View file

@ -1,9 +1,10 @@
Features:
* Support for self-signed certs and client certs for mTLS (#215, #530, ntfy-android#149)
* Support for updating and deleting notifications (#303, #1536, ntfy-android#151, thanks to @wunter8 for the initial implementation)
* Support for self-signed certs and client certs for mTLS (#215, #530, ntfy-android#149, thanks to @cyb3rko for reviewing)
* Connection error dialog to help diagnose connection issues
Maintenance + bug fixes:
* Use server-specific user for attachment downloads (#1529, thanks to @ManInDark for reporting)
* Use server-specific user for attachment downloads (#1529, thanks to @ManInDark for reporting and testing)
* Fix crash in sharing dialog (thanks to @rogeliodh)
* Fix crash when exiting multi-delete in detail view
* Fix potential crashes with icon downloader and backuper