From 03ed75009156952a59cd45fa457082fde8fb1101 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Mon, 5 Jan 2026 21:37:32 -0500 Subject: [PATCH 01/21] Sorta works --- .../io.heckel.ntfy.db.Database/16.json | 375 ++++++++++++++++++ .../java/io/heckel/ntfy/backup/Backuper.kt | 3 + .../main/java/io/heckel/ntfy/db/Database.kt | 44 +- .../main/java/io/heckel/ntfy/db/Repository.kt | 36 +- .../main/java/io/heckel/ntfy/msg/Message.kt | 1 + .../io/heckel/ntfy/msg/NotificationParser.kt | 36 +- .../java/io/heckel/ntfy/ui/DetailActivity.kt | 4 +- .../java/io/heckel/ntfy/ui/DetailAdapter.kt | 10 +- .../java/io/heckel/ntfy/ui/DetailViewModel.kt | 9 +- app/src/main/res/values/strings.xml | 1 + .../heckel/ntfy/firebase/FirebaseService.kt | 2 + 11 files changed, 496 insertions(+), 25 deletions(-) create mode 100644 app/schemas/io.heckel.ntfy.db.Database/16.json diff --git a/app/schemas/io.heckel.ntfy.db.Database/16.json b/app/schemas/io.heckel.ntfy.db.Database/16.json new file mode 100644 index 00000000..eaae9ee3 --- /dev/null +++ b/app/schemas/io.heckel.ntfy.db.Database/16.json @@ -0,0 +1,375 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "0ad0c63dd982870549d612ba1dc41608", + "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, `sid` 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": "sid", + "columnName": "sid", + "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": "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": "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" + ] + } + } + ], + "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, '0ad0c63dd982870549d612ba1dc41608')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt index a2027b13..aaa00b48 100644 --- a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt +++ b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt @@ -184,6 +184,7 @@ class Backuper(val context: Context) { id = n.id, subscriptionId = n.subscriptionId, timestamp = n.timestamp, + sid = n.sid ?: n.id, title = n.title, message = n.message, contentType = n.contentType, @@ -315,6 +316,7 @@ class Backuper(val context: Context) { id = n.id, subscriptionId = n.subscriptionId, timestamp = n.timestamp, + sid = n.sid, title = n.title, message = n.message, contentType = n.contentType, @@ -391,6 +393,7 @@ data class Notification( val id: String, val subscriptionId: Long, val timestamp: Long, + val sid: String?, // Sequence ID for updating notifications val title: String, val message: String, val contentType: String, // "" or "text/markdown" (empty assumes "text/plain") diff --git a/app/src/main/java/io/heckel/ntfy/db/Database.kt b/app/src/main/java/io/heckel/ntfy/db/Database.kt index b28d6696..fbe8c8de 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -96,7 +96,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 = "sid") val sid: 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) @@ -109,7 +110,32 @@ data class Notification( @ColumnInfo(name = "actions") val actions: List?, @Embedded(prefix = "attachment_") val attachment: Attachment?, @ColumnInfo(name = "deleted") val deleted: Boolean, -) + @Ignore val originalTime: Long = 0 // Original time of the notification sequence (computed, not stored) +) { + // Secondary constructor for Room (without ignored fields) + constructor( + id: String, + subscriptionId: Long, + timestamp: Long, + sid: String, + title: String, + message: String, + contentType: String, + encoding: String, + notificationId: Int, + priority: Int, + tags: String, + click: String, + icon: Icon?, + actions: List?, + attachment: Attachment?, + deleted: Boolean + ) : this( + id, subscriptionId, timestamp, sid, title, message, contentType, encoding, + notificationId, priority, tags, click, icon, actions, attachment, deleted, + originalTime = 0 + ) +} fun Notification.isMarkdown(): Boolean { return contentType == "text/markdown" @@ -208,7 +234,7 @@ data class LogEntry( this(0, timestamp, tag, level, message, exception) } -@androidx.room.Database(entities = [Subscription::class, Notification::class, User::class, CustomHeader::class, LogEntry::class], version = 15) +@androidx.room.Database(entities = [Subscription::class, Notification::class, User::class, CustomHeader::class, LogEntry::class], version = 16) @TypeConverters(Converters::class) abstract class Database : RoomDatabase() { abstract fun subscriptionDao(): SubscriptionDao @@ -239,6 +265,7 @@ abstract class Database : RoomDatabase() { .addMigrations(MIGRATION_12_13) .addMigrations(MIGRATION_13_14) .addMigrations(MIGRATION_14_15) + .addMigrations(MIGRATION_15_16) .fallbackToDestructiveMigration(true) .build() this.instance = instance @@ -356,6 +383,14 @@ abstract class Database : RoomDatabase() { db.execSQL("CREATE TABLE CustomHeader (baseUrl TEXT NOT NULL, name TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY(baseUrl, name))") } } + + private val MIGRATION_15_16 = object : Migration(15, 16) { + override fun migrate(db: SupportSQLiteDatabase) { + // Add sid column, defaulting to the id value + db.execSQL("ALTER TABLE Notification ADD COLUMN sid TEXT NOT NULL DEFAULT ''") + db.execSQL("UPDATE Notification SET sid = id WHERE sid = ''") + } + } } } @@ -474,6 +509,9 @@ interface NotificationDao { @Query("UPDATE notification SET deleted = 1 WHERE id = :notificationId") fun markAsDeleted(notificationId: String) + @Query("UPDATE notification SET deleted = 1 WHERE subscriptionId = :subscriptionId AND sid = :sid") + fun markAsDeletedBySid(subscriptionId: Long, sid: String) + @Query("UPDATE notification SET deleted = 1 WHERE subscriptionId = :subscriptionId") fun markAllAsDeleted(subscriptionId: Long) diff --git a/app/src/main/java/io/heckel/ntfy/db/Repository.kt b/app/src/main/java/io/heckel/ntfy/db/Repository.kt index d042fc56..ee608dda 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Repository.kt @@ -111,7 +111,37 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database) } fun getNotificationsLiveData(subscriptionId: Long): LiveData> { - return notificationDao.listFlow(subscriptionId).asLiveData() + return notificationDao.listFlow(subscriptionId).asLiveData().map { notifications -> + groupNotificationsBySid(notifications) + } + } + + /** + * Group notifications by sid (sequence ID) and return only the latest version of each. + * Also tracks the original time (earliest timestamp) for each sequence. + * Notifications are already sorted by timestamp DESC from the DAO query. + */ + private fun groupNotificationsBySid(notifications: List): List { + val latestBySid = mutableMapOf() + val originalTimeBySid = mutableMapOf() + + for (notification in notifications) { + // Track the latest notification for each sid (first one since sorted DESC) + if (!latestBySid.containsKey(notification.sid)) { + latestBySid[notification.sid] = notification + } + // Track the original (earliest) time for each sid + val currentOriginal = originalTimeBySid[notification.sid] + if (currentOriginal == null || notification.timestamp < currentOriginal) { + originalTimeBySid[notification.sid] = notification.timestamp + } + } + + // Return latest notifications with originalTime set, sorted by timestamp descending + return latestBySid.values.map { notification -> + val originalTime = originalTimeBySid[notification.sid] ?: notification.timestamp + notification.copy(originalTime = originalTime) + }.sortedByDescending { it.timestamp }.toMutableList() } fun clearAllNotificationIds(subscriptionId: Long) { @@ -151,6 +181,10 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database) notificationDao.markAsDeleted(notificationId) } + fun markAsDeletedBySid(subscriptionId: Long, sid: String) { + notificationDao.markAsDeletedBySid(subscriptionId, sid) + } + fun markAllAsDeleted(subscriptionId: Long) { notificationDao.markAllAsDeleted(subscriptionId) } diff --git a/app/src/main/java/io/heckel/ntfy/msg/Message.kt b/app/src/main/java/io/heckel/ntfy/msg/Message.kt index b34965f2..6b10bdb2 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/Message.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/Message.kt @@ -9,6 +9,7 @@ import com.google.gson.annotations.SerializedName data class Message( val id: String, val time: Long, + val sid: String?, // Sequence ID for updating notifications val event: String, val topic: String, val priority: Int?, 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 5af07418..557c971d 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt @@ -32,29 +32,29 @@ class NotificationParser { url = message.attachment.url, ) } else null - val actions = if (message.actions != null) { - message.actions.map { a -> - Action( - id = a.id, - action = a.action, - label = a.label, - clear = a.clear, - url = a.url, - method = a.method, - headers = a.headers, - body = a.body, - intent = a.intent, - extras = a.extras, - progress = null, - error = null - ) - } - } else null + val actions = message.actions?.map { a -> + Action( + id = a.id, + action = a.action, + label = a.label, + clear = a.clear, + url = a.url, + method = a.method, + headers = a.headers, + body = a.body, + intent = a.intent, + extras = a.extras, + progress = null, + error = null + ) + } 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 val notification = Notification( id = message.id, subscriptionId = subscriptionId, timestamp = message.time, + sid = sid, title = message.title ?: "", message = message.message, contentType = message.contentType ?: "", diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt index 6c4d369e..4c62cd62 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -332,11 +332,13 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet override fun onSwiped(viewHolder: RecyclerView.ViewHolder, swipeDir: Int) { val notification = adapter.get(viewHolder.absoluteAdapterPosition) lifecycleScope.launch(Dispatchers.IO) { - repository.markAsDeleted(notification.id) + // Delete all notifications in the sequence (same sid) + repository.markAsDeletedBySid(notification.subscriptionId, notification.sid) } 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) } } diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt index 67f8bd9c..8041a8d4 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt @@ -114,7 +114,15 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: val unmatchedTags = unmatchedTags(splitTags(notification.tags)) val message = maybeAppendActionErrors(formatMessage(notification), notification) - dateView.text = formatDateShort(notification.timestamp) + val isModified = notification.originalTime != 0L && notification.originalTime != notification.timestamp + val dateText = if (isModified) { + val originalDate = formatDateShort(notification.originalTime) + val modifiedDate = formatDateShort(notification.timestamp) + context.getString(R.string.detail_item_date_modified, originalDate, modifiedDate) + } else { + formatDateShort(notification.timestamp) + } + dateView.text = dateText if (notification.isMarkdown()) { messageView.autoLinkMask = 0 markwon.setMarkdown(messageView, message.toString()) diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt index 8606a64a..1ec10cf8 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt @@ -15,7 +15,14 @@ class DetailViewModel(private val repository: Repository) : ViewModel() { } fun markAsDeleted(notificationId: String) = viewModelScope.launch(Dispatchers.IO) { - repository.markAsDeleted(notificationId) + // Look up the notification to get its subscriptionId and sid, then delete the entire sequence + val notification = repository.getNotification(notificationId) + if (notification != null) { + repository.markAsDeletedBySid(notification.subscriptionId, notification.sid) + } else { + // Fallback to deleting by id if notification not found + repository.markAsDeleted(notificationId) + } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7a3276f7..80538ecd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -145,6 +145,7 @@ Instant delivery off Subscribed to topic %1$s Tags: %1$s + %1$s (modified %2$s) Notification deleted Undo Open file 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 4459df72..a348a063 100644 --- a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt +++ b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt @@ -99,6 +99,7 @@ class FirebaseService : FirebaseMessagingService() { val attachmentSize = data["attachment_size"]?.toLongOrNull()?.nullIfZero() val attachmentExpires = data["attachment_expires"]?.toLongOrNull()?.nullIfZero() val attachmentUrl = data["attachment_url"] + val sid = data["sid"] 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}") @@ -131,6 +132,7 @@ class FirebaseService : FirebaseMessagingService() { id = id, subscriptionId = subscription.id, timestamp = timestamp, + sid = sid ?: id, title = title ?: "", message = message, contentType = contentType ?: "", From a23d3991a11e1948ab47cead2b0b97e0076b5fe8 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Mon, 5 Jan 2026 21:46:55 -0500 Subject: [PATCH 02/21] Remove "modified" time --- .../main/java/io/heckel/ntfy/db/Database.kt | 27 +------------------ .../main/java/io/heckel/ntfy/db/Repository.kt | 18 +++---------- .../java/io/heckel/ntfy/ui/DetailAdapter.kt | 10 +------ app/src/main/res/values/strings.xml | 1 - 4 files changed, 5 insertions(+), 51 deletions(-) diff --git a/app/src/main/java/io/heckel/ntfy/db/Database.kt b/app/src/main/java/io/heckel/ntfy/db/Database.kt index fbe8c8de..bdefa523 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -110,32 +110,7 @@ data class Notification( @ColumnInfo(name = "actions") val actions: List?, @Embedded(prefix = "attachment_") val attachment: Attachment?, @ColumnInfo(name = "deleted") val deleted: Boolean, - @Ignore val originalTime: Long = 0 // Original time of the notification sequence (computed, not stored) -) { - // Secondary constructor for Room (without ignored fields) - constructor( - id: String, - subscriptionId: Long, - timestamp: Long, - sid: String, - title: String, - message: String, - contentType: String, - encoding: String, - notificationId: Int, - priority: Int, - tags: String, - click: String, - icon: Icon?, - actions: List?, - attachment: Attachment?, - deleted: Boolean - ) : this( - id, subscriptionId, timestamp, sid, title, message, contentType, encoding, - notificationId, priority, tags, click, icon, actions, attachment, deleted, - originalTime = 0 - ) -} +) fun Notification.isMarkdown(): Boolean { return contentType == "text/markdown" diff --git a/app/src/main/java/io/heckel/ntfy/db/Repository.kt b/app/src/main/java/io/heckel/ntfy/db/Repository.kt index ee608dda..1a4a50ce 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Repository.kt @@ -118,30 +118,18 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database) /** * Group notifications by sid (sequence ID) and return only the latest version of each. - * Also tracks the original time (earliest timestamp) for each sequence. * Notifications are already sorted by timestamp DESC from the DAO query. */ private fun groupNotificationsBySid(notifications: List): List { val latestBySid = mutableMapOf() - val originalTimeBySid = mutableMapOf() - for (notification in notifications) { - // Track the latest notification for each sid (first one since sorted DESC) + // Keep only the first (latest by timestamp) notification for each sid if (!latestBySid.containsKey(notification.sid)) { latestBySid[notification.sid] = notification } - // Track the original (earliest) time for each sid - val currentOriginal = originalTimeBySid[notification.sid] - if (currentOriginal == null || notification.timestamp < currentOriginal) { - originalTimeBySid[notification.sid] = notification.timestamp - } } - - // Return latest notifications with originalTime set, sorted by timestamp descending - return latestBySid.values.map { notification -> - val originalTime = originalTimeBySid[notification.sid] ?: notification.timestamp - notification.copy(originalTime = originalTime) - }.sortedByDescending { it.timestamp }.toMutableList() + // Return sorted by timestamp descending (latest first) + return latestBySid.values.sortedByDescending { it.timestamp }.toMutableList() } fun clearAllNotificationIds(subscriptionId: Long) { diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt index 8041a8d4..67f8bd9c 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt @@ -114,15 +114,7 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: val unmatchedTags = unmatchedTags(splitTags(notification.tags)) val message = maybeAppendActionErrors(formatMessage(notification), notification) - val isModified = notification.originalTime != 0L && notification.originalTime != notification.timestamp - val dateText = if (isModified) { - val originalDate = formatDateShort(notification.originalTime) - val modifiedDate = formatDateShort(notification.timestamp) - context.getString(R.string.detail_item_date_modified, originalDate, modifiedDate) - } else { - formatDateShort(notification.timestamp) - } - dateView.text = dateText + dateView.text = formatDateShort(notification.timestamp) if (notification.isMarkdown()) { messageView.autoLinkMask = 0 markwon.setMarkdown(messageView, message.toString()) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 80538ecd..7a3276f7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -145,7 +145,6 @@ Instant delivery off Subscribed to topic %1$s Tags: %1$s - %1$s (modified %2$s) Notification deleted Undo Open file From 41f46b667f6cede3fc92d861c25e69a18b0faa5b Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Mon, 5 Jan 2026 22:09:15 -0500 Subject: [PATCH 03/21] Android notification ID --- app/src/main/java/io/heckel/ntfy/msg/ApiService.kt | 5 ++--- .../java/io/heckel/ntfy/msg/NotificationParser.kt | 9 ++++++--- .../main/java/io/heckel/ntfy/service/WsConnection.kt | 3 +-- app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt | 4 ++-- app/src/main/java/io/heckel/ntfy/util/Util.kt | 11 +++++++++++ app/src/main/java/io/heckel/ntfy/work/PollWorker.kt | 4 ++-- .../java/io/heckel/ntfy/firebase/FirebaseService.kt | 7 ++++--- 7 files changed, 28 insertions(+), 15 deletions(-) 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)) { From 75010680b2124dcce1ec3db69a62c9dcf1c3b162 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Tue, 6 Jan 2026 12:06:42 -0500 Subject: [PATCH 04/21] Comment --- app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt | 1 - app/src/main/java/io/heckel/ntfy/util/Util.kt | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) 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 95358034..9c949432 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt @@ -51,7 +51,6 @@ 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, 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 70e6ee8c..9b54e173 100644 --- a/app/src/main/java/io/heckel/ntfy/util/Util.kt +++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt @@ -511,6 +511,5 @@ fun Long.nullIfZero(): Long? { */ 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) + return if (hash == 0 || hash == Int.MIN_VALUE) 1 else abs(hash) } From 6b2aa25a68079af82b7f897bb2c6b4026798d045 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Tue, 6 Jan 2026 14:23:02 -0500 Subject: [PATCH 05/21] Deletions --- app/src/main/java/io/heckel/ntfy/db/Repository.kt | 5 +++++ app/src/main/java/io/heckel/ntfy/msg/Message.kt | 1 + .../java/io/heckel/ntfy/msg/NotificationDispatcher.kt | 10 ++++++++++ .../main/java/io/heckel/ntfy/msg/NotificationParser.kt | 2 +- .../java/io/heckel/ntfy/firebase/FirebaseService.kt | 6 ++++-- 5 files changed, 21 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/io/heckel/ntfy/db/Repository.kt b/app/src/main/java/io/heckel/ntfy/db/Repository.kt index 1a4a50ce..45f40abe 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Repository.kt @@ -152,6 +152,11 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database) if (maybeExistingNotification != null) { return false } + // If this is a delete notification, mark existing notifications with this sid as deleted + if (notification.deleted) { + notificationDao.markAsDeletedBySid(notification.subscriptionId, notification.sid) + // Still add the delete notification to the database for proper sequence tracking + } subscriptionDao.updateLastNotificationId(notification.subscriptionId, notification.id) notificationDao.add(notification) return true diff --git a/app/src/main/java/io/heckel/ntfy/msg/Message.kt b/app/src/main/java/io/heckel/ntfy/msg/Message.kt index 6b10bdb2..4eb0f192 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/Message.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/Message.kt @@ -10,6 +10,7 @@ data class Message( val id: String, val time: Long, val sid: String?, // Sequence ID for updating notifications + val deleted: Boolean?, // true if the notification sequence is deleted val event: String, val topic: String, val priority: Int?, diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt index 3afabdb5..92600ef4 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt @@ -7,6 +7,7 @@ import io.heckel.ntfy.db.Subscription import io.heckel.ntfy.util.Log import io.heckel.ntfy.up.Distributor import io.heckel.ntfy.util.decodeBytesMessage +import io.heckel.ntfy.util.deriveNotificationId import io.heckel.ntfy.util.safeLet /** @@ -25,6 +26,12 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { fun dispatch(subscription: Subscription, notification: Notification) { Log.d(TAG, "Dispatching $notification for subscription $subscription") + // Cancel existing notification if this is a delete message + if (notification.deleted) { + Log.d(TAG, "Cancelling notification for deleted message with sid ${notification.sid}, notificationId ${notification.notificationId}") + notifier.cancel(notification.notificationId) + } + val muted = getMuted(subscription) val notify = shouldNotify(subscription, notification, muted) val broadcast = shouldBroadcast(subscription) @@ -76,6 +83,9 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { } private fun shouldNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean { + if (notification.deleted) { + return false + } if (subscription.upAppId != null) { return false } 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 9c949432..fa4eac98 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt @@ -68,7 +68,7 @@ class NotificationParser { actions = actions, attachment = attachment, notificationId = notificationId, - deleted = false + deleted = message.deleted ?: false ) return NotificationWithTopic(message.topic, 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 47250481..18688913 100644 --- a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt +++ b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt @@ -100,6 +100,7 @@ class FirebaseService : FirebaseMessagingService() { val attachmentExpires = data["attachment_expires"]?.toLongOrNull()?.nullIfZero() val attachmentUrl = data["attachment_url"] val sid = data["sid"] + val deleted = data["deleted"]?.toBooleanStrictOrNull() == true 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}") @@ -129,6 +130,7 @@ class FirebaseService : FirebaseMessagingService() { } else null val icon: Icon? = if (iconUrl != null && iconUrl != "") Icon(url = iconUrl) else null val actualSid = sid ?: id + val notificationId = if (deleted) 0 else deriveNotificationId(actualSid) val notification = Notification( id = id, subscriptionId = subscription.id, @@ -144,8 +146,8 @@ class FirebaseService : FirebaseMessagingService() { icon = icon, actions = parser.parseActions(actions), attachment = attachment, - notificationId = deriveNotificationId(actualSid), - deleted = false + notificationId = notificationId, + deleted = deleted ) if (repository.addNotification(notification)) { Log.d(TAG, "Dispatching notification: from=${remoteMessage.from}, fcmprio=${remoteMessage.priority}, fcmprio_orig=${remoteMessage.originalPriority}, data=${data}") From 85a5bb1b160b2976b2ada0b2c287b50d6bf47b52 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Thu, 8 Jan 2026 12:40:57 -0500 Subject: [PATCH 06/21] Poller --- .../main/java/io/heckel/ntfy/db/Database.kt | 3 + .../main/java/io/heckel/ntfy/db/Repository.kt | 13 ++- .../java/io/heckel/ntfy/msg/ApiService.kt | 4 +- .../io/heckel/ntfy/msg/NotificationParser.kt | 9 +- .../main/java/io/heckel/ntfy/msg/Poller.kt | 86 +++++++++++++++++++ .../io/heckel/ntfy/service/WsConnection.kt | 2 +- .../java/io/heckel/ntfy/ui/DetailActivity.kt | 17 ++-- .../java/io/heckel/ntfy/ui/MainActivity.kt | 25 +++--- .../java/io/heckel/ntfy/work/PollWorker.kt | 21 ++--- .../heckel/ntfy/firebase/FirebaseService.kt | 3 +- 10 files changed, 138 insertions(+), 45 deletions(-) create mode 100644 app/src/main/java/io/heckel/ntfy/msg/Poller.kt diff --git a/app/src/main/java/io/heckel/ntfy/db/Database.kt b/app/src/main/java/io/heckel/ntfy/db/Database.kt index bdefa523..d8856574 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -487,6 +487,9 @@ interface NotificationDao { @Query("UPDATE notification SET deleted = 1 WHERE subscriptionId = :subscriptionId AND sid = :sid") fun markAsDeletedBySid(subscriptionId: Long, sid: String) + @Query("DELETE FROM notification WHERE subscriptionId = :subscriptionId AND sid = :sid") + fun deleteBySid(subscriptionId: Long, sid: String) + @Query("UPDATE notification SET deleted = 1 WHERE subscriptionId = :subscriptionId") fun markAllAsDeleted(subscriptionId: Long) diff --git a/app/src/main/java/io/heckel/ntfy/db/Repository.kt b/app/src/main/java/io/heckel/ntfy/db/Repository.kt index 45f40abe..a3d4b7b4 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Repository.kt @@ -152,10 +152,13 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database) if (maybeExistingNotification != null) { return false } - // If this is a delete notification, mark existing notifications with this sid as deleted + // Delete old notifications with the same SID (this is an update to an existing sequence) + if (notification.sid.isNotEmpty()) { + notificationDao.deleteBySid(notification.subscriptionId, notification.sid) + } + // If this is a delete notification, don't add it to the database if (notification.deleted) { - notificationDao.markAsDeletedBySid(notification.subscriptionId, notification.sid) - // Still add the delete notification to the database for proper sequence tracking + return false } subscriptionDao.updateLastNotificationId(notification.subscriptionId, notification.id) notificationDao.add(notification) @@ -178,6 +181,10 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database) notificationDao.markAsDeletedBySid(subscriptionId, sid) } + fun deleteBySid(subscriptionId: Long, sid: String) { + notificationDao.deleteBySid(subscriptionId, sid) + } + fun markAllAsDeleted(subscriptionId: Long) { notificationDao.markAllAsDeleted(subscriptionId) } 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 c7aaa6a7..a03e358f 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt @@ -138,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) // No notification when we poll + parser.parse(line, subscriptionId = subscriptionId) } Log.d(TAG, "Notifications: $notifications") @@ -169,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, notify = true, subscriptionId = 0) // subscriptionId to be set downstream + val notification = parser.parseWithTopic(line, 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 fa4eac98..951a89f4 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt @@ -14,12 +14,12 @@ import java.lang.reflect.Type class NotificationParser { private val gson = Gson() - fun parse(s: String, subscriptionId: Long = 0, notify: Boolean = false): Notification? { - val notificationWithTopic = parseWithTopic(s, subscriptionId = subscriptionId, notify = notify) + fun parse(s: String, subscriptionId: Long = 0,): Notification? { + val notificationWithTopic = parseWithTopic(s, subscriptionId = subscriptionId) return notificationWithTopic?.notification } - fun parseWithTopic(s: String, subscriptionId: Long = 0, notify: Boolean = false): NotificationWithTopic? { + fun parseWithTopic(s: String, subscriptionId: Long = 0): NotificationWithTopic? { val message = gson.fromJson(s, Message::class.java) if (message.event != ApiService.EVENT_MESSAGE) { return null @@ -51,7 +51,6 @@ 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 - val notificationId = if (notify) deriveNotificationId(sid) else 0 val notification = Notification( id = message.id, subscriptionId = subscriptionId, @@ -67,7 +66,7 @@ class NotificationParser { icon = icon, actions = actions, attachment = attachment, - notificationId = notificationId, + notificationId = deriveNotificationId(sid), deleted = message.deleted ?: false ) return NotificationWithTopic(message.topic, notification) diff --git a/app/src/main/java/io/heckel/ntfy/msg/Poller.kt b/app/src/main/java/io/heckel/ntfy/msg/Poller.kt new file mode 100644 index 00000000..17abe4bf --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/msg/Poller.kt @@ -0,0 +1,86 @@ +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.db.User +import io.heckel.ntfy.util.Log +import io.heckel.ntfy.util.deriveNotificationId + +/** + * Polls the server for notifications and updates the repository. + * Groups notifications by SID 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 + * @param user The user for authentication (may be null) + * @param since The message ID to poll since (null for all cached messages) + * @param notify Whether to derive notification IDs for popup notifications + */ + suspend fun poll( + subscription: Subscription, + user: User?, + since: String? = null, + notify: Boolean = false + ): List { + val notifications = api.poll( + subscriptionId = subscription.id, + baseUrl = subscription.baseUrl, + topic = subscription.topic, + user = user, + since = since + ) + return processNotifications(subscription.id, notifications, notify) + } + + /** + * Processes a list of notifications: groups by SID, 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, + notify: Boolean + ): List { + // Group by SID and only keep the latest notification for each sequence + val latestBySid = notifications + .groupBy { it.sid.ifEmpty { it.id } } + .mapValues { (_, notifs) -> notifs.maxByOrNull { it.timestamp } } + .values + .filterNotNull() + + // Delete sequences where the latest notification is marked as deleted + latestBySid.filter { it.deleted }.forEach { notification -> + val sid = notification.sid.ifEmpty { notification.id } + Log.d(TAG, "Deleting notifications with sid $sid") + repository.deleteBySid(subscriptionId, sid) + } + + // Add only non-deleted latest notifications + val notificationsToAdd = latestBySid + .filter { !it.deleted } + .map { if (notify) it.copy(notificationId = deriveNotificationId(it.sid)) else it } + + val addedNotifications = mutableListOf() + notificationsToAdd.forEach { notification -> + if (repository.addNotification(notification)) { + addedNotifications.add(notification) + } + } + + return addedNotifications + } + + companion object { + private const val TAG = "NtfyPoller" + } +} 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 b119f5dd..d67f8ef3 100644 --- a/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt +++ b/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt @@ -147,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, notify = true) + 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 diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt index 4c62cd62..bc38d915 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -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 @@ -231,8 +233,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, user) } catch (e: Exception) { Log.e(TAG, "Unable to fetch notifications: ${e.message}", e) } @@ -707,14 +708,16 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet 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 toastMessage = if (newNotifications.isEmpty()) { + val addedNotifications = poller.poll( + subscription = subscription, + user = user, + since = subscription.lastNotificationId + ) + val toastMessage = if (addedNotifications.isEmpty()) { getString(R.string.refresh_message_no_results) } else { - getString(R.string.refresh_message_result, newNotifications.size) + getString(R.string.refresh_message_result, addedNotifications.size) } - newNotifications.forEach { notification -> repository.addNotification(notification) } runOnUiThread { Toast.makeText(this@DetailActivity, toastMessage, Toast.LENGTH_LONG).show() mainListContainer.isRefreshing = false 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 2d2a7228..ff11fe62 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -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 @@ -67,7 +68,6 @@ 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 @@ -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 @@ -663,9 +664,8 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific 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) - notifications.forEach { notification -> - repository.addNotification(notification) + val addedNotifications = poller.poll(subscription, user) + addedNotifications.forEach { notification -> if (notification.icon != null) { DownloadManager.enqueue(this@MainActivity, notification.id, userAction = false, DownloadType.ICON) } @@ -705,14 +705,15 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific Log.d(TAG, "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) - newNotifications.forEach { notification -> - newNotificationsCount++ - val notificationWithId = notification.copy(notificationId = deriveNotificationId(notification.sid)) - if (repository.addNotification(notificationWithId)) { - dispatcher?.dispatch(subscription, notificationWithId) - } + val addedNotifications = poller.poll( + subscription = subscription, + user = user, + since = subscription.lastNotificationId, + notify = true + ) + newNotificationsCount += addedNotifications.size + addedNotifications.forEach { notification -> + dispatcher?.dispatch(subscription, notification) } } catch (e: Exception) { val topic = displayName(appBaseUrl, subscription) 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 e227745a..f122b015 100644 --- a/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt @@ -7,8 +7,8 @@ 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 io.heckel.ntfy.util.deriveNotificationId import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -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) @@ -40,20 +41,14 @@ 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, + val addedNotifications = poller.poll( + subscription = subscription, user = user, - since = subscription.lastNotificationId + since = subscription.lastNotificationId, + notify = true ) - val newNotifications = repository - .onlyNewNotifications(subscription.id, notifications) - .map { it.copy(notificationId = deriveNotificationId(it.sid)) } - newNotifications.forEach { notification -> - if (repository.addNotification(notification)) { - dispatcher.dispatch(subscription, notification) - } + addedNotifications.forEach { notification -> + dispatcher.dispatch(subscription, notification) } } catch (e: Exception) { Log.e(TAG, "Failed checking messages: ${e.message}", e) 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 18688913..4a00199d 100644 --- a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt +++ b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt @@ -130,7 +130,6 @@ class FirebaseService : FirebaseMessagingService() { } else null val icon: Icon? = if (iconUrl != null && iconUrl != "") Icon(url = iconUrl) else null val actualSid = sid ?: id - val notificationId = if (deleted) 0 else deriveNotificationId(actualSid) val notification = Notification( id = id, subscriptionId = subscription.id, @@ -146,7 +145,7 @@ class FirebaseService : FirebaseMessagingService() { icon = icon, actions = parser.parseActions(actions), attachment = attachment, - notificationId = notificationId, + notificationId = deriveNotificationId(actualSid), deleted = deleted ) if (repository.addNotification(notification)) { From 2607454efa3c0789d889fcbaff52bbc54ad98bd0 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Thu, 8 Jan 2026 13:45:25 -0500 Subject: [PATCH 07/21] Cancel notifications --- .../java/io/heckel/ntfy/msg/ApiService.kt | 17 ++++++++++++--- .../heckel/ntfy/service/SubscriberService.kt | 11 +++++++++- .../heckel/ntfy/firebase/FirebaseService.kt | 21 ++++++++++++++++--- 3 files changed, 42 insertions(+), 7 deletions(-) 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 a03e358f..b8cf0f65 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt @@ -8,10 +8,21 @@ import io.heckel.ntfy.db.CustomHeader import io.heckel.ntfy.db.Notification import io.heckel.ntfy.db.Repository import io.heckel.ntfy.db.User -import io.heckel.ntfy.util.* -import okhttp3.* -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import io.heckel.ntfy.util.ALL_PRIORITIES +import io.heckel.ntfy.util.Log +import io.heckel.ntfy.util.PRIORITY_DEFAULT +import io.heckel.ntfy.util.topicUrl +import io.heckel.ntfy.util.topicUrlAuth +import io.heckel.ntfy.util.topicUrlJson +import io.heckel.ntfy.util.topicUrlJsonPoll +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Credentials +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response import java.io.IOException import java.net.URLEncoder import java.nio.charset.StandardCharsets.UTF_8 diff --git a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt index bfda891d..7a8cb52e 100644 --- a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt +++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt @@ -312,7 +312,16 @@ 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)) { + // Note: This logic is duplicated in the FirebaseService::handleMessage() method + // and the web app hooks.js:handleNotification(). + + // Delete existing notification with same sid, if any + if (notification.sid.isNotEmpty()) { + repository.deleteBySid(subscription.id, notification.sid) + } + // Add notification to database and dispatch to be displayed/canceled + val added = repository.addNotification(notification) + if (added || notification.deleted) { Log.d(TAG, "[$url] Dispatching notification $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 4a00199d..ce6b1f35 100644 --- a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt +++ b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt @@ -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,11 @@ 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.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 @@ -148,7 +153,17 @@ class FirebaseService : FirebaseMessagingService() { notificationId = deriveNotificationId(actualSid), deleted = deleted ) - if (repository.addNotification(notification)) { + + // Note: This logic is duplicated in the SubscriberService::onNotificationReceived() method + // and the web app hooks.js:handleNotification(). + + // Delete existing notification with same sid, if any + if (notification.sid.isNotEmpty()) { + repository.deleteBySid(subscription.id, notification.sid) + } + // Add notification to database and dispatch to be displayed/canceled + val added = repository.addNotification(notification) + if (added || notification.deleted) { Log.d(TAG, "Dispatching notification: from=${remoteMessage.from}, fcmprio=${remoteMessage.priority}, fcmprio_orig=${remoteMessage.originalPriority}, data=${data}") dispatcher.dispatch(subscription, notification) } From d1d42cea84a23e220fd81209889c593d632aec6e Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Thu, 8 Jan 2026 14:41:37 -0500 Subject: [PATCH 08/21] Rename to sequence_id, manual fixes --- .../io.heckel.ntfy.db.Database/16.json | 10 ++--- .../java/io/heckel/ntfy/backup/Backuper.kt | 7 ++-- .../main/java/io/heckel/ntfy/db/Database.kt | 19 ++++----- .../main/java/io/heckel/ntfy/db/Repository.kt | 39 +++---------------- .../main/java/io/heckel/ntfy/msg/Message.kt | 2 +- .../heckel/ntfy/msg/NotificationDispatcher.kt | 2 +- .../io/heckel/ntfy/msg/NotificationParser.kt | 6 +-- .../main/java/io/heckel/ntfy/msg/Poller.kt | 22 +++++------ .../heckel/ntfy/service/SubscriberService.kt | 6 +-- .../java/io/heckel/ntfy/ui/DetailActivity.kt | 3 +- .../java/io/heckel/ntfy/ui/DetailViewModel.kt | 9 +---- app/src/main/java/io/heckel/ntfy/util/Util.kt | 6 +-- .../heckel/ntfy/firebase/FirebaseService.kt | 14 +++---- 13 files changed, 54 insertions(+), 91 deletions(-) diff --git a/app/schemas/io.heckel.ntfy.db.Database/16.json b/app/schemas/io.heckel.ntfy.db.Database/16.json index eaae9ee3..cdb17ec7 100644 --- a/app/schemas/io.heckel.ntfy.db.Database/16.json +++ b/app/schemas/io.heckel.ntfy.db.Database/16.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 16, - "identityHash": "0ad0c63dd982870549d612ba1dc41608", + "identityHash": "5a649b90cc16ad4cf10a0ee6f3e312b3", "entities": [ { "tableName": "Subscription", @@ -118,7 +118,7 @@ }, { "tableName": "Notification", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `sid` 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`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `sequence_id` 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", @@ -139,8 +139,8 @@ "notNull": true }, { - "fieldPath": "sid", - "columnName": "sid", + "fieldPath": "sequenceId", + "columnName": "sequence_id", "affinity": "TEXT", "notNull": true }, @@ -369,7 +369,7 @@ ], "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, '0ad0c63dd982870549d612ba1dc41608')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5a649b90cc16ad4cf10a0ee6f3e312b3')" ] } } \ No newline at end of file diff --git a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt index aaa00b48..b8d75b9b 100644 --- a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt +++ b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt @@ -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 @@ -184,7 +185,7 @@ class Backuper(val context: Context) { id = n.id, subscriptionId = n.subscriptionId, timestamp = n.timestamp, - sid = n.sid ?: n.id, + sequenceId = n.sequenceId ?: n.id, title = n.title, message = n.message, contentType = n.contentType, @@ -316,7 +317,7 @@ class Backuper(val context: Context) { id = n.id, subscriptionId = n.subscriptionId, timestamp = n.timestamp, - sid = n.sid, + sequenceId = n.sequenceId, title = n.title, message = n.message, contentType = n.contentType, @@ -393,7 +394,7 @@ data class Notification( val id: String, val subscriptionId: Long, val timestamp: Long, - val sid: String?, // Sequence ID for updating notifications + @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") diff --git a/app/src/main/java/io/heckel/ntfy/db/Database.kt b/app/src/main/java/io/heckel/ntfy/db/Database.kt index d8856574..f42a882e 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -97,7 +97,7 @@ data class Notification( @ColumnInfo(name = "id") val id: String, @ColumnInfo(name = "subscriptionId") val subscriptionId: Long, @ColumnInfo(name = "timestamp") val timestamp: Long, // Unix timestamp in seconds - @ColumnInfo(name = "sid") val sid: String, // Sequence ID for updating notifications + @ColumnInfo(name = "sequence_id") 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) @@ -361,9 +361,9 @@ abstract class Database : RoomDatabase() { private val MIGRATION_15_16 = object : Migration(15, 16) { override fun migrate(db: SupportSQLiteDatabase) { - // Add sid column, defaulting to the id value - db.execSQL("ALTER TABLE Notification ADD COLUMN sid TEXT NOT NULL DEFAULT ''") - db.execSQL("UPDATE Notification SET sid = id WHERE sid = ''") + // Add sequence_id column, defaulting to the id value + db.execSQL("ALTER TABLE Notification ADD COLUMN sequence_id TEXT NOT NULL DEFAULT ''") + db.execSQL("UPDATE Notification SET sequence_id = id WHERE sequence_id = ''") } } } @@ -457,9 +457,6 @@ interface NotificationDao { @Query("SELECT * FROM notification WHERE subscriptionId = :subscriptionId AND deleted != 1 ORDER BY timestamp DESC") fun listFlow(subscriptionId: Long): Flow> - @Query("SELECT id FROM notification WHERE subscriptionId = :subscriptionId") // Includes deleted - fun listIds(subscriptionId: Long): List - @Query("SELECT * FROM notification WHERE deleted = 1 AND attachment_contentUri <> ''") fun listDeletedWithAttachments(): List @@ -484,11 +481,11 @@ interface NotificationDao { @Query("UPDATE notification SET deleted = 1 WHERE id = :notificationId") fun markAsDeleted(notificationId: String) - @Query("UPDATE notification SET deleted = 1 WHERE subscriptionId = :subscriptionId AND sid = :sid") - fun markAsDeletedBySid(subscriptionId: Long, sid: String) + @Query("UPDATE notification SET deleted = 1 WHERE subscriptionId = :subscriptionId AND sequence_id = :sequenceId") + fun markAsDeletedBySequenceId(subscriptionId: Long, sequenceId: String) - @Query("DELETE FROM notification WHERE subscriptionId = :subscriptionId AND sid = :sid") - fun deleteBySid(subscriptionId: Long, sid: String) + @Query("DELETE FROM notification WHERE subscriptionId = :subscriptionId AND sequence_id = :sequenceId") + fun deleteBySequenceId(subscriptionId: Long, sequenceId: String) @Query("UPDATE notification SET deleted = 1 WHERE subscriptionId = :subscriptionId") fun markAllAsDeleted(subscriptionId: Long) diff --git a/app/src/main/java/io/heckel/ntfy/db/Repository.kt b/app/src/main/java/io/heckel/ntfy/db/Repository.kt index a3d4b7b4..7e958242 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Repository.kt @@ -111,25 +111,7 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database) } fun getNotificationsLiveData(subscriptionId: Long): LiveData> { - return notificationDao.listFlow(subscriptionId).asLiveData().map { notifications -> - groupNotificationsBySid(notifications) - } - } - - /** - * Group notifications by sid (sequence ID) and return only the latest version of each. - * Notifications are already sorted by timestamp DESC from the DAO query. - */ - private fun groupNotificationsBySid(notifications: List): List { - val latestBySid = mutableMapOf() - for (notification in notifications) { - // Keep only the first (latest by timestamp) notification for each sid - if (!latestBySid.containsKey(notification.sid)) { - latestBySid[notification.sid] = notification - } - } - // Return sorted by timestamp descending (latest first) - return latestBySid.values.sortedByDescending { it.timestamp }.toMutableList() + return notificationDao.listFlow(subscriptionId).asLiveData() } fun clearAllNotificationIds(subscriptionId: Long) { @@ -140,11 +122,6 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database) return notificationDao.get(notificationId) } - fun onlyNewNotifications(subscriptionId: Long, notifications: List): List { - val existingIds = notificationDao.listIds(subscriptionId) - return notifications.filterNot { existingIds.contains(it.id) } - } - @Suppress("RedundantSuspendModifier") @WorkerThread suspend fun addNotification(notification: Notification): Boolean { @@ -152,9 +129,9 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database) if (maybeExistingNotification != null) { return false } - // Delete old notifications with the same SID (this is an update to an existing sequence) - if (notification.sid.isNotEmpty()) { - notificationDao.deleteBySid(notification.subscriptionId, notification.sid) + // Delete old notifications with the same sequence ID (this is an update to an existing sequence) + if (notification.sequenceId.isNotEmpty()) { + notificationDao.deleteBySequenceId(notification.subscriptionId, notification.sequenceId) } // If this is a delete notification, don't add it to the database if (notification.deleted) { @@ -177,12 +154,8 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database) notificationDao.markAsDeleted(notificationId) } - fun markAsDeletedBySid(subscriptionId: Long, sid: String) { - notificationDao.markAsDeletedBySid(subscriptionId, sid) - } - - fun deleteBySid(subscriptionId: Long, sid: String) { - notificationDao.deleteBySid(subscriptionId, sid) + fun deleteBySequenceId(subscriptionId: Long, sequenceId: String) { + notificationDao.deleteBySequenceId(subscriptionId, sequenceId) } fun markAllAsDeleted(subscriptionId: Long) { diff --git a/app/src/main/java/io/heckel/ntfy/msg/Message.kt b/app/src/main/java/io/heckel/ntfy/msg/Message.kt index 4eb0f192..861107db 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/Message.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/Message.kt @@ -9,7 +9,7 @@ import com.google.gson.annotations.SerializedName data class Message( val id: String, val time: Long, - val sid: String?, // Sequence ID for updating notifications + @SerializedName("sequence_id") val sequenceId: String?, // Sequence ID for updating notifications val deleted: Boolean?, // true if the notification sequence is deleted val event: String, val topic: String, diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt index 92600ef4..8b0b6f7f 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt @@ -28,7 +28,7 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { // Cancel existing notification if this is a delete message if (notification.deleted) { - Log.d(TAG, "Cancelling notification for deleted message with sid ${notification.sid}, notificationId ${notification.notificationId}") + Log.d(TAG, "Cancelling notification for deleted message with sequenceId ${notification.sequenceId}, notificationId ${notification.notificationId}") notifier.cancel(notification.notificationId) } 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 951a89f4..6889988b 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt @@ -50,12 +50,12 @@ 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 + val sequenceId = message.sequenceId ?: message.id // Default to id if sequenceId not provided val notification = Notification( id = message.id, subscriptionId = subscriptionId, timestamp = message.time, - sid = sid, + sequenceId = sequenceId, title = message.title ?: "", message = message.message, contentType = message.contentType ?: "", @@ -66,7 +66,7 @@ class NotificationParser { icon = icon, actions = actions, attachment = attachment, - notificationId = deriveNotificationId(sid), + notificationId = deriveNotificationId(sequenceId), deleted = message.deleted ?: false ) return NotificationWithTopic(message.topic, notification) diff --git a/app/src/main/java/io/heckel/ntfy/msg/Poller.kt b/app/src/main/java/io/heckel/ntfy/msg/Poller.kt index 17abe4bf..c92a6e6b 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/Poller.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/Poller.kt @@ -9,7 +9,7 @@ import io.heckel.ntfy.util.deriveNotificationId /** * Polls the server for notifications and updates the repository. - * Groups notifications by SID and only keeps the latest for each sequence. + * Groups notifications by sequenceId and only keeps the latest for each sequence. * Deletes sequences where the latest notification is marked as deleted. */ class Poller( @@ -42,7 +42,7 @@ class Poller( } /** - * Processes a list of notifications: groups by SID, deletes deleted sequences, + * 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. */ @@ -51,24 +51,24 @@ class Poller( notifications: List, notify: Boolean ): List { - // Group by SID and only keep the latest notification for each sequence - val latestBySid = notifications - .groupBy { it.sid.ifEmpty { it.id } } + // 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() // Delete sequences where the latest notification is marked as deleted - latestBySid.filter { it.deleted }.forEach { notification -> - val sid = notification.sid.ifEmpty { notification.id } - Log.d(TAG, "Deleting notifications with sid $sid") - repository.deleteBySid(subscriptionId, sid) + latestBySequenceId.filter { it.deleted }.forEach { notification -> + val sequenceId = notification.sequenceId.ifEmpty { notification.id } + Log.d(TAG, "Deleting notifications with sequenceId $sequenceId") + repository.deleteBySequenceId(subscriptionId, sequenceId) } // Add only non-deleted latest notifications - val notificationsToAdd = latestBySid + val notificationsToAdd = latestBySequenceId .filter { !it.deleted } - .map { if (notify) it.copy(notificationId = deriveNotificationId(it.sid)) else it } + .map { if (notify) it.copy(notificationId = deriveNotificationId(it.sequenceId)) else it } val addedNotifications = mutableListOf() notificationsToAdd.forEach { notification -> diff --git a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt index 7a8cb52e..36464e3a 100644 --- a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt +++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt @@ -315,9 +315,9 @@ class SubscriberService : Service() { // Note: This logic is duplicated in the FirebaseService::handleMessage() method // and the web app hooks.js:handleNotification(). - // Delete existing notification with same sid, if any - if (notification.sid.isNotEmpty()) { - repository.deleteBySid(subscription.id, notification.sid) + // Delete existing notification with same sequenceId, if any + if (notification.sequenceId.isNotEmpty()) { + repository.deleteBySequenceId(subscription.id, notification.sequenceId) } // Add notification to database and dispatch to be displayed/canceled val added = repository.addNotification(notification) diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt index bc38d915..570a6218 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -333,8 +333,7 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet override fun onSwiped(viewHolder: RecyclerView.ViewHolder, swipeDir: Int) { val notification = adapter.get(viewHolder.absoluteAdapterPosition) lifecycleScope.launch(Dispatchers.IO) { - // Delete all notifications in the sequence (same sid) - repository.markAsDeletedBySid(notification.subscriptionId, notification.sid) + repository.markAsDeleted(notification.id) } val snackbar = Snackbar.make(mainList, R.string.detail_item_snack_deleted, Snackbar.LENGTH_SHORT) snackbar.setAction(R.string.detail_item_snack_undo) { diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt index 1ec10cf8..8606a64a 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt @@ -15,14 +15,7 @@ class DetailViewModel(private val repository: Repository) : ViewModel() { } fun markAsDeleted(notificationId: String) = viewModelScope.launch(Dispatchers.IO) { - // Look up the notification to get its subscriptionId and sid, then delete the entire sequence - val notification = repository.getNotification(notificationId) - if (notification != null) { - repository.markAsDeletedBySid(notification.subscriptionId, notification.sid) - } else { - // Fallback to deleting by id if notification not found - repository.markAsDeleted(notificationId) - } + repository.markAsDeleted(notificationId) } } 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 9b54e173..c2d45d18 100644 --- a/app/src/main/java/io/heckel/ntfy/util/Util.kt +++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt @@ -505,11 +505,11 @@ fun Long.nullIfZero(): Long? { } /** - * Derives a stable notification ID from a string (typically the sid or id). + * 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(sid: String): Int { - val hash = sid.hashCode() +fun deriveNotificationId(sequenceId: String): Int { + val hash = sequenceId.hashCode() return if (hash == 0 || hash == Int.MIN_VALUE) 1 else abs(hash) } 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 ce6b1f35..89a78c98 100644 --- a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt +++ b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt @@ -104,7 +104,7 @@ class FirebaseService : FirebaseMessagingService() { val attachmentSize = data["attachment_size"]?.toLongOrNull()?.nullIfZero() val attachmentExpires = data["attachment_expires"]?.toLongOrNull()?.nullIfZero() val attachmentUrl = data["attachment_url"] - val sid = data["sid"] + val sequenceId = data["sequence_id"] val deleted = data["deleted"]?.toBooleanStrictOrNull() == true val truncated = (data["truncated"] ?: "") == "1" if (id == null || topic == null || message == null || timestamp == null) { @@ -134,12 +134,12 @@ class FirebaseService : FirebaseMessagingService() { ) } else null val icon: Icon? = if (iconUrl != null && iconUrl != "") Icon(url = iconUrl) else null - val actualSid = sid ?: id + val actualSequenceId = sequenceId ?: id val notification = Notification( id = id, subscriptionId = subscription.id, timestamp = timestamp, - sid = actualSid, + sequenceId = actualSequenceId, title = title ?: "", message = message, contentType = contentType ?: "", @@ -150,16 +150,16 @@ class FirebaseService : FirebaseMessagingService() { icon = icon, actions = parser.parseActions(actions), attachment = attachment, - notificationId = deriveNotificationId(actualSid), + notificationId = deriveNotificationId(actualSequenceId), deleted = deleted ) // Note: This logic is duplicated in the SubscriberService::onNotificationReceived() method // and the web app hooks.js:handleNotification(). - // Delete existing notification with same sid, if any - if (notification.sid.isNotEmpty()) { - repository.deleteBySid(subscription.id, notification.sid) + // Delete existing notification with same sequenceId, if any + if (notification.sequenceId.isNotEmpty()) { + repository.deleteBySequenceId(subscription.id, notification.sequenceId) } // Add notification to database and dispatch to be displayed/canceled val added = repository.addNotification(notification) From fb604e5dc097202b030346edec62452ce0c05467 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Thu, 8 Jan 2026 14:48:58 -0500 Subject: [PATCH 09/21] Mark as deleted instead of hard delete --- app/src/main/java/io/heckel/ntfy/db/Database.kt | 3 --- app/src/main/java/io/heckel/ntfy/db/Repository.kt | 8 ++++---- app/src/main/java/io/heckel/ntfy/msg/Poller.kt | 2 +- .../main/java/io/heckel/ntfy/service/SubscriberService.kt | 2 +- .../play/java/io/heckel/ntfy/firebase/FirebaseService.kt | 2 +- 5 files changed, 7 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/io/heckel/ntfy/db/Database.kt b/app/src/main/java/io/heckel/ntfy/db/Database.kt index f42a882e..6313bce4 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -484,9 +484,6 @@ interface NotificationDao { @Query("UPDATE notification SET deleted = 1 WHERE subscriptionId = :subscriptionId AND sequence_id = :sequenceId") fun markAsDeletedBySequenceId(subscriptionId: Long, sequenceId: String) - @Query("DELETE FROM notification WHERE subscriptionId = :subscriptionId AND sequence_id = :sequenceId") - fun deleteBySequenceId(subscriptionId: Long, sequenceId: String) - @Query("UPDATE notification SET deleted = 1 WHERE subscriptionId = :subscriptionId") fun markAllAsDeleted(subscriptionId: Long) diff --git a/app/src/main/java/io/heckel/ntfy/db/Repository.kt b/app/src/main/java/io/heckel/ntfy/db/Repository.kt index 7e958242..2c158fb1 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Repository.kt @@ -129,9 +129,9 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database) if (maybeExistingNotification != null) { return false } - // Delete old notifications with the same sequence ID (this is an update to an existing sequence) + // Mark old notifications with the same sequence ID as deleted (this is an update to an existing sequence) if (notification.sequenceId.isNotEmpty()) { - notificationDao.deleteBySequenceId(notification.subscriptionId, notification.sequenceId) + notificationDao.markAsDeletedBySequenceId(notification.subscriptionId, notification.sequenceId) } // If this is a delete notification, don't add it to the database if (notification.deleted) { @@ -154,8 +154,8 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database) notificationDao.markAsDeleted(notificationId) } - fun deleteBySequenceId(subscriptionId: Long, sequenceId: String) { - notificationDao.deleteBySequenceId(subscriptionId, sequenceId) + fun markAsDeletedBySequenceId(subscriptionId: Long, sequenceId: String) { + notificationDao.markAsDeletedBySequenceId(subscriptionId, sequenceId) } fun markAllAsDeleted(subscriptionId: Long) { diff --git a/app/src/main/java/io/heckel/ntfy/msg/Poller.kt b/app/src/main/java/io/heckel/ntfy/msg/Poller.kt index c92a6e6b..102b685c 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/Poller.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/Poller.kt @@ -62,7 +62,7 @@ class Poller( latestBySequenceId.filter { it.deleted }.forEach { notification -> val sequenceId = notification.sequenceId.ifEmpty { notification.id } Log.d(TAG, "Deleting notifications with sequenceId $sequenceId") - repository.deleteBySequenceId(subscriptionId, sequenceId) + repository.markAsDeletedBySequenceId(subscriptionId, sequenceId) } // Add only non-deleted latest notifications diff --git a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt index 36464e3a..83e889c8 100644 --- a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt +++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt @@ -317,7 +317,7 @@ class SubscriberService : Service() { // Delete existing notification with same sequenceId, if any if (notification.sequenceId.isNotEmpty()) { - repository.deleteBySequenceId(subscription.id, notification.sequenceId) + repository.markAsDeletedBySequenceId(subscription.id, notification.sequenceId) } // Add notification to database and dispatch to be displayed/canceled val added = repository.addNotification(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 89a78c98..3fb09032 100644 --- a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt +++ b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt @@ -159,7 +159,7 @@ class FirebaseService : FirebaseMessagingService() { // Delete existing notification with same sequenceId, if any if (notification.sequenceId.isNotEmpty()) { - repository.deleteBySequenceId(subscription.id, notification.sequenceId) + repository.markAsDeletedBySequenceId(subscription.id, notification.sequenceId) } // Add notification to database and dispatch to be displayed/canceled val added = repository.addNotification(notification) From d11bb4657b9c2dde9b0a7c92aea5577207ed8dd2 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Thu, 8 Jan 2026 21:37:50 -0500 Subject: [PATCH 10/21] DEFUNCT: Update to use new events --- .../main/java/io/heckel/ntfy/db/Database.kt | 2 +- .../main/java/io/heckel/ntfy/db/Repository.kt | 8 ++-- .../java/io/heckel/ntfy/msg/ApiService.kt | 2 + .../ntfy/msg/DownloadAttachmentWorker.kt | 3 +- .../io/heckel/ntfy/msg/NotificationParser.kt | 12 +++-- .../java/io/heckel/ntfy/ui/DetailActivity.kt | 2 +- .../java/io/heckel/ntfy/ui/MainActivity.kt | 2 +- .../heckel/ntfy/firebase/FirebaseService.kt | 45 +++++++++++++++++-- 8 files changed, 61 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/io/heckel/ntfy/db/Database.kt b/app/src/main/java/io/heckel/ntfy/db/Database.kt index 6313bce4..363c25d5 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -476,7 +476,7 @@ 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 deleted = 1 WHERE id = :notificationId") fun markAsDeleted(notificationId: String) diff --git a/app/src/main/java/io/heckel/ntfy/db/Repository.kt b/app/src/main/java/io/heckel/ntfy/db/Repository.kt index 2c158fb1..f829ed18 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Repository.kt @@ -114,10 +114,6 @@ 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) } @@ -162,6 +158,10 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database) notificationDao.markAllAsDeleted(subscriptionId) } + fun markAllAsRead(subscriptionId: Long) { + return notificationDao.markAllAsRead(subscriptionId) + } + fun markAsDeletedIfOlderThan(subscriptionId: Long, olderThanTimestamp: Long) { notificationDao.markAsDeletedIfOlderThan(subscriptionId, olderThanTimestamp) } 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 b8cf0f65..acff695a 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt @@ -236,6 +236,8 @@ class ApiService(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_READ = "message_read" const val EVENT_KEEPALIVE = "keepalive" const val EVENT_POLL_REQUEST = "poll_request" diff --git a/app/src/main/java/io/heckel/ntfy/msg/DownloadAttachmentWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadAttachmentWorker.kt index f1cf32a9..f320e136 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/DownloadAttachmentWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadAttachmentWorker.kt @@ -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 -> { 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 6889988b..77827df8 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt @@ -14,16 +14,22 @@ import java.lang.reflect.Type class NotificationParser { private val gson = Gson() - fun parse(s: String, subscriptionId: Long = 0,): Notification? { + fun parse(s: String, subscriptionId: Long = 0): Notification? { val notificationWithTopic = parseWithTopic(s, subscriptionId = subscriptionId) return notificationWithTopic?.notification } fun parseWithTopic(s: String, subscriptionId: Long = 0): NotificationWithTopic? { val message = gson.fromJson(s, Message::class.java) - if (message.event != ApiService.EVENT_MESSAGE) { + // Accept message, message_delete, and message_read events + val isValidEvent = message.event == ApiService.EVENT_MESSAGE || + message.event == ApiService.EVENT_MESSAGE_DELETE || + message.event == ApiService.EVENT_MESSAGE_READ + if (!isValidEvent) { return null } + // Set deleted flag based on event type + val deleted = message.event == ApiService.EVENT_MESSAGE_DELETE val attachment = if (message.attachment?.url != null) { Attachment( name = message.attachment.name, @@ -67,7 +73,7 @@ class NotificationParser { actions = actions, attachment = attachment, notificationId = deriveNotificationId(sequenceId), - deleted = message.deleted ?: false + deleted = deleted ) return NotificationWithTopic(message.topic, notification) } diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt index 570a6218..0f56d3ad 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -520,7 +520,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 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 ff11fe62..6a996844 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -762,7 +762,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() 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 3fb09032..d9e3921d 100644 --- a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt +++ b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt @@ -17,6 +17,7 @@ import io.heckel.ntfy.db.Notification 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 @@ -49,6 +50,8 @@ class FirebaseService : FirebaseMessagingService() { val data = remoteMessage.data when (data["event"]) { ApiService.EVENT_MESSAGE -> handleMessage(remoteMessage) + ApiService.EVENT_MESSAGE_DELETE -> handleMessageDelete(remoteMessage) + ApiService.EVENT_MESSAGE_READ -> handleMessageRead(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}") @@ -85,6 +88,43 @@ 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 handleMessageRead(remoteMessage: RemoteMessage) { + val data = remoteMessage.data + val topic = data["topic"] ?: return + val sequenceId = data["sequence_id"] ?: return + Log.d(TAG, "Received message_read: 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 the notification as read (not new) + // Note: We don't have a "mark as read by sequenceId" method yet, so we just log for now + Log.d(TAG, "Marking notification with sequenceId $sequenceId as read") + // TODO: Implement markAsReadBySequenceId if needed + } + } + private fun handleMessage(remoteMessage: RemoteMessage) { val data = remoteMessage.data val id = data["id"] @@ -105,7 +145,6 @@ class FirebaseService : FirebaseMessagingService() { val attachmentExpires = data["attachment_expires"]?.toLongOrNull()?.nullIfZero() val attachmentUrl = data["attachment_url"] val sequenceId = data["sequence_id"] - val deleted = data["deleted"]?.toBooleanStrictOrNull() == true 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}") @@ -151,7 +190,7 @@ class FirebaseService : FirebaseMessagingService() { actions = parser.parseActions(actions), attachment = attachment, notificationId = deriveNotificationId(actualSequenceId), - deleted = deleted + deleted = false ) // Note: This logic is duplicated in the SubscriberService::onNotificationReceived() method @@ -163,7 +202,7 @@ class FirebaseService : FirebaseMessagingService() { } // Add notification to database and dispatch to be displayed/canceled val added = repository.addNotification(notification) - if (added || notification.deleted) { + if (added) { Log.d(TAG, "Dispatching notification: from=${remoteMessage.from}, fcmprio=${remoteMessage.priority}, fcmprio_orig=${remoteMessage.originalPriority}, data=${data}") dispatcher.dispatch(subscription, notification) } From 28eb389aaf4f1395addb175186a42b35420e52ab Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sat, 10 Jan 2026 12:30:04 -0500 Subject: [PATCH 11/21] DB schema --- .../io.heckel.ntfy.db.Database/17.json | 429 ++++++++++++++++++ 1 file changed, 429 insertions(+) create mode 100644 app/schemas/io.heckel.ntfy.db.Database/17.json diff --git a/app/schemas/io.heckel.ntfy.db.Database/17.json b/app/schemas/io.heckel.ntfy.db.Database/17.json new file mode 100644 index 00000000..538d1c3c --- /dev/null +++ b/app/schemas/io.heckel.ntfy.db.Database/17.json @@ -0,0 +1,429 @@ +{ + "formatVersion": 1, + "database": { + "version": 17, + "identityHash": "e62fdd1a12610e3514eff4dc83dcc0b8", + "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, `sequence_id` 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": "sequence_id", + "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, 'e62fdd1a12610e3514eff4dc83dcc0b8')" + ] + } +} \ No newline at end of file From 35ad1a302b3335a976ce60ac36ebff228a28f81c Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sat, 10 Jan 2026 20:21:39 -0500 Subject: [PATCH 12/21] Manual review --- .../main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt index 8b0b6f7f..7c6bf58b 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt @@ -4,10 +4,9 @@ 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.deriveNotificationId import io.heckel.ntfy.util.safeLet /** @@ -83,9 +82,6 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { } private fun shouldNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean { - if (notification.deleted) { - return false - } if (subscription.upAppId != null) { return false } From a69614df3542a11f2b78caf2ba16972d036ec555 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Tue, 13 Jan 2026 16:02:37 -0500 Subject: [PATCH 13/21] Manual updates --- .../io.heckel.ntfy.db.Database/18.json | 429 ++++++++++++++++++ app/src/main/AndroidManifest.xml | 3 +- .../main/java/io/heckel/ntfy/db/Database.kt | 29 +- .../main/java/io/heckel/ntfy/db/Repository.kt | 11 +- .../java/io/heckel/ntfy/msg/ApiService.kt | 2 +- .../heckel/ntfy/msg/NotificationDispatcher.kt | 34 +- .../io/heckel/ntfy/msg/NotificationParser.kt | 10 +- .../main/java/io/heckel/ntfy/msg/Poller.kt | 27 +- .../heckel/ntfy/service/SubscriberService.kt | 40 +- .../heckel/ntfy/firebase/FirebaseService.kt | 19 +- 10 files changed, 541 insertions(+), 63 deletions(-) create mode 100644 app/schemas/io.heckel.ntfy.db.Database/18.json diff --git a/app/schemas/io.heckel.ntfy.db.Database/18.json b/app/schemas/io.heckel.ntfy.db.Database/18.json new file mode 100644 index 00000000..b6339b5b --- /dev/null +++ b/app/schemas/io.heckel.ntfy.db.Database/18.json @@ -0,0 +1,429 @@ +{ + "formatVersion": 1, + "database": { + "version": 18, + "identityHash": "e62fdd1a12610e3514eff4dc83dcc0b8", + "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, `sequence_id` 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": "sequence_id", + "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, 'e62fdd1a12610e3514eff4dc83dcc0b8')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 136653b1..c930da0f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -36,8 +36,7 @@ + android:exported="true"> diff --git a/app/src/main/java/io/heckel/ntfy/db/Database.kt b/app/src/main/java/io/heckel/ntfy/db/Database.kt index a74abe7b..82dbdaa8 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -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 @@ -158,7 +159,30 @@ data class Notification( @ColumnInfo(name = "actions") val actions: List?, @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_read) +) { + 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?, + 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" @@ -571,6 +595,9 @@ interface NotificationDao { @Query("UPDATE notification SET notificationId = 0 WHERE subscriptionId = :subscriptionId") fun markAllAsRead(subscriptionId: Long) + @Query("UPDATE notification SET notificationId = 0 WHERE subscriptionId = :subscriptionId AND sequence_id = :sequenceId") + fun markAsReadBySequenceId(subscriptionId: Long, sequenceId: String) + @Query("UPDATE notification SET deleted = 1 WHERE id = :notificationId") fun markAsDeleted(notificationId: String) diff --git a/app/src/main/java/io/heckel/ntfy/db/Repository.kt b/app/src/main/java/io/heckel/ntfy/db/Repository.kt index b354c960..0c280e97 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Repository.kt @@ -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 @@ -125,17 +126,13 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database) @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) } - // If this is a delete notification, don't add it to the database - if (notification.deleted) { - return false - } subscriptionDao.updateLastNotificationId(notification.subscriptionId, notification.id) notificationDao.add(notification) return true @@ -165,6 +162,10 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database) return notificationDao.markAllAsRead(subscriptionId) } + fun markAsReadBySequenceId(subscriptionId: Long, sequenceId: String) { + notificationDao.markAsReadBySequenceId(subscriptionId, sequenceId) + } + fun markAsDeletedIfOlderThan(subscriptionId: Long, olderThanTimestamp: Long) { notificationDao.markAsDeletedIfOlderThan(subscriptionId, olderThanTimestamp) } 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 673893a7..9437cea0 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt @@ -155,7 +155,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()) } diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt index 74ba34d6..f8e9bfba 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt @@ -25,19 +25,16 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { fun dispatch(subscription: Subscription, notification: Notification) { Log.d(TAG, "Dispatching $notification for subscription $subscription") - // Cancel existing notification if this is a delete message - if (notification.deleted) { - Log.d(TAG, "Cancelling notification for deleted message with sequenceId ${notification.sequenceId}, notificationId ${notification.notificationId}") - notifier.cancel(notification.notificationId) - } - + 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) { @@ -58,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 @@ -78,13 +75,20 @@ 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_READ || notification.event == ApiService.EVENT_MESSAGE_DELETE } private fun shouldNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean { if (subscription.upAppId != null) { return false } + if (notification.event != ApiService.EVENT_MESSAGE) { + return false + } val priority = if (notification.priority > 0) notification.priority else 3 val minPriority = if (subscription.minPriority > 0) subscription.minPriority else repository.getMinPriority() if (priority < minPriority) { @@ -94,15 +98,15 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { return !detailsVisible && !muted } - private fun shouldBroadcast(subscription: Subscription): Boolean { + private fun shouldBroadcast(subscription: Subscription, notification: Notification): Boolean { if (subscription.upAppId != null) { // Never broadcast for UnifiedPush subscriptions return false } - return repository.getBroadcastEnabled() + return repository.getBroadcastEnabled() && notification.event == ApiService.EVENT_MESSAGE } - 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 { 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 77827df8..bc5eb361 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt @@ -21,15 +21,12 @@ class NotificationParser { fun parseWithTopic(s: String, subscriptionId: Long = 0): NotificationWithTopic? { val message = gson.fromJson(s, Message::class.java) - // Accept message, message_delete, and message_read events - val isValidEvent = message.event == ApiService.EVENT_MESSAGE || + val validEvent = message.event == ApiService.EVENT_MESSAGE || message.event == ApiService.EVENT_MESSAGE_DELETE || message.event == ApiService.EVENT_MESSAGE_READ - if (!isValidEvent) { + if (!validEvent) { return null } - // Set deleted flag based on event type - val deleted = message.event == ApiService.EVENT_MESSAGE_DELETE val attachment = if (message.attachment?.url != null) { Attachment( name = message.attachment.name, @@ -73,7 +70,8 @@ class NotificationParser { actions = actions, attachment = attachment, notificationId = deriveNotificationId(sequenceId), - deleted = deleted + deleted = false, + event = message.event ) return NotificationWithTopic(message.topic, notification) } diff --git a/app/src/main/java/io/heckel/ntfy/msg/Poller.kt b/app/src/main/java/io/heckel/ntfy/msg/Poller.kt index 102b685c..1828b57c 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/Poller.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/Poller.kt @@ -58,18 +58,27 @@ class Poller( .values .filterNotNull() - // Delete sequences where the latest notification is marked as deleted - latestBySequenceId.filter { it.deleted }.forEach { notification -> - val sequenceId = notification.sequenceId.ifEmpty { notification.id } - Log.d(TAG, "Deleting notifications with sequenceId $sequenceId") - repository.markAsDeletedBySequenceId(subscriptionId, sequenceId) - } + // Handle delete and read events + latestBySequenceId + .filter { it.event == ApiService.EVENT_MESSAGE_READ || 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_READ -> { + Log.d(TAG, "Marking notifications as read with sequenceId $sequenceId") + repository.markAsReadBySequenceId(subscriptionId, sequenceId) + } + } + } - // Add only non-deleted latest notifications + // Add only regular message notifications val notificationsToAdd = latestBySequenceId - .filter { !it.deleted } + .filter { it.event == ApiService.EVENT_MESSAGE } .map { if (notify) it.copy(notificationId = deriveNotificationId(it.sequenceId)) else it } - val addedNotifications = mutableListOf() notificationsToAdd.forEach { notification -> if (repository.addNotification(notification)) { diff --git a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt index 9d431a69..960103fe 100644 --- a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt +++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt @@ -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 @@ -322,21 +328,27 @@ class SubscriberService : Service() { // Note: This logic is duplicated in the FirebaseService::handleMessage() method // and the web app hooks.js:handleNotification(). - // Delete existing notification with same sequenceId, if any - if (notification.sequenceId.isNotEmpty()) { - repository.markAsDeletedBySequenceId(subscription.id, notification.sequenceId) - } - // Add notification to database and dispatch to be displayed/canceled - val added = repository.addNotification(notification) - if (added || notification.deleted) { - Log.d(TAG, "[$url] Dispatching notification $notification") - dispatcher.dispatch(subscription, notification) - } - wakeLock?.let { - if (it.isHeld) { - it.release() + when (notification.event) { + ApiService.EVENT_MESSAGE_READ -> { + if (notification.sequenceId.isNotEmpty()) { + repository.markAsReadBySequenceId(subscription.id, notification.sequenceId) + } + dispatcher.dispatch(subscription, notification) + } + 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() } } } } 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 d9e3921d..9f30fb99 100644 --- a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt +++ b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt @@ -118,10 +118,13 @@ class FirebaseService : FirebaseMessagingService() { val baseUrl = getString(R.string.app_base_url) val subscription = repository.getSubscription(baseUrl, topic) ?: return@launch - // Mark the notification as read (not new) - // Note: We don't have a "mark as read by sequenceId" method yet, so we just log for now - Log.d(TAG, "Marking notification with sequenceId $sequenceId as read") - // TODO: Implement markAsReadBySequenceId if needed + // 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) } } @@ -190,17 +193,13 @@ class FirebaseService : FirebaseMessagingService() { actions = parser.parseActions(actions), attachment = attachment, notificationId = deriveNotificationId(actualSequenceId), - deleted = false + deleted = false, + event = ApiService.EVENT_MESSAGE ) // Note: This logic is duplicated in the SubscriberService::onNotificationReceived() method // and the web app hooks.js:handleNotification(). - // Delete existing notification with same sequenceId, if any - if (notification.sequenceId.isNotEmpty()) { - repository.markAsDeletedBySequenceId(subscription.id, notification.sequenceId) - } - // Add notification to database and dispatch to be displayed/canceled val added = repository.addNotification(notification) if (added) { Log.d(TAG, "Dispatching notification: from=${remoteMessage.from}, fcmprio=${remoteMessage.priority}, fcmprio_orig=${remoteMessage.originalPriority}, data=${data}") From b96c2fb2ed4658d5fa5f86c293d86ec0bfd353bd Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Tue, 13 Jan 2026 16:30:57 -0500 Subject: [PATCH 14/21] Done --- app/schemas/io.heckel.ntfy.db.Database/18.json | 8 ++++---- app/src/main/java/io/heckel/ntfy/db/Database.kt | 12 ++++++------ app/src/main/java/io/heckel/ntfy/msg/ApiService.kt | 2 +- app/src/main/java/io/heckel/ntfy/msg/Message.kt | 3 +-- .../io/heckel/ntfy/msg/NotificationDispatcher.kt | 2 +- .../java/io/heckel/ntfy/msg/NotificationParser.kt | 4 ++-- app/src/main/java/io/heckel/ntfy/msg/Poller.kt | 4 ++-- .../java/io/heckel/ntfy/service/SubscriberService.kt | 2 +- .../java/io/heckel/ntfy/firebase/FirebaseService.kt | 6 +++--- 9 files changed, 21 insertions(+), 22 deletions(-) diff --git a/app/schemas/io.heckel.ntfy.db.Database/18.json b/app/schemas/io.heckel.ntfy.db.Database/18.json index b6339b5b..1bfa0e9a 100644 --- a/app/schemas/io.heckel.ntfy.db.Database/18.json +++ b/app/schemas/io.heckel.ntfy.db.Database/18.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 18, - "identityHash": "e62fdd1a12610e3514eff4dc83dcc0b8", + "identityHash": "02663facc6503d5ea7015397d5e8cc94", "entities": [ { "tableName": "Subscription", @@ -118,7 +118,7 @@ }, { "tableName": "Notification", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `sequence_id` 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`))", + "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", @@ -140,7 +140,7 @@ }, { "fieldPath": "sequenceId", - "columnName": "sequence_id", + "columnName": "sequenceId", "affinity": "TEXT", "notNull": true }, @@ -423,7 +423,7 @@ ], "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, 'e62fdd1a12610e3514eff4dc83dcc0b8')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '02663facc6503d5ea7015397d5e8cc94')" ] } } \ No newline at end of file diff --git a/app/src/main/java/io/heckel/ntfy/db/Database.kt b/app/src/main/java/io/heckel/ntfy/db/Database.kt index 82dbdaa8..57fa984f 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -146,7 +146,7 @@ data class Notification( @ColumnInfo(name = "id") val id: String, @ColumnInfo(name = "subscriptionId") val subscriptionId: Long, @ColumnInfo(name = "timestamp") val timestamp: Long, // Unix timestamp in seconds - @ColumnInfo(name = "sequence_id") val sequenceId: String, // Sequence ID for updating notifications + @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) @@ -159,7 +159,7 @@ data class Notification( @ColumnInfo(name = "actions") val actions: List?, @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_read) + @Ignore val event: String = ApiService.EVENT_MESSAGE, // In-memory event type (message, message_delete, message_clear) ) { constructor( id: String, @@ -479,8 +479,8 @@ abstract class Database : RoomDatabase() { private val MIGRATION_17_18 = object : Migration(17, 18) { override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE Notification ADD COLUMN sequence_id TEXT NOT NULL DEFAULT ''") - db.execSQL("UPDATE Notification SET sequence_id = id WHERE sequence_id = ''") + db.execSQL("ALTER TABLE Notification ADD COLUMN sequenceId TEXT NOT NULL DEFAULT ''") + db.execSQL("UPDATE Notification SET sequenceId = id WHERE sequenceId = ''") } } } @@ -595,13 +595,13 @@ interface NotificationDao { @Query("UPDATE notification SET notificationId = 0 WHERE subscriptionId = :subscriptionId") fun markAllAsRead(subscriptionId: Long) - @Query("UPDATE notification SET notificationId = 0 WHERE subscriptionId = :subscriptionId AND sequence_id = :sequenceId") + @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 sequence_id = :sequenceId") + @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") 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 9437cea0..8970b4ef 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt @@ -198,7 +198,7 @@ class ApiService(private val context: Context) { const val CONTROL_TOPIC = "~control" const val EVENT_MESSAGE = "message" const val EVENT_MESSAGE_DELETE = "message_delete" - const val EVENT_MESSAGE_READ = "message_read" + const val EVENT_MESSAGE_CLEAR = "message_clear" const val EVENT_KEEPALIVE = "keepalive" const val EVENT_POLL_REQUEST = "poll_request" } diff --git a/app/src/main/java/io/heckel/ntfy/msg/Message.kt b/app/src/main/java/io/heckel/ntfy/msg/Message.kt index 861107db..8aa62b5f 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/Message.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/Message.kt @@ -10,7 +10,6 @@ data class Message( val id: String, val time: Long, @SerializedName("sequence_id") val sequenceId: String?, // Sequence ID for updating notifications - val deleted: Boolean?, // true if the notification sequence is deleted val event: String, val topic: String, val priority: Int?, @@ -19,7 +18,7 @@ data class Message( val icon: String?, val actions: List?, val title: String?, - val message: String, + val message: String?, @SerializedName("content_type") val contentType: String?, val encoding: String?, val attachment: MessageAttachment?, diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt index f8e9bfba..1c309355 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt @@ -79,7 +79,7 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { } private fun shouldCancel(notification: Notification): Boolean { - return notification.event == ApiService.EVENT_MESSAGE_READ || notification.event == ApiService.EVENT_MESSAGE_DELETE + return notification.event == ApiService.EVENT_MESSAGE_CLEAR || notification.event == ApiService.EVENT_MESSAGE_DELETE } private fun shouldNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean { 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 bc5eb361..0eb11808 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt @@ -23,7 +23,7 @@ class NotificationParser { val message = gson.fromJson(s, Message::class.java) val validEvent = message.event == ApiService.EVENT_MESSAGE || message.event == ApiService.EVENT_MESSAGE_DELETE || - message.event == ApiService.EVENT_MESSAGE_READ + message.event == ApiService.EVENT_MESSAGE_CLEAR if (!validEvent) { return null } @@ -60,7 +60,7 @@ class NotificationParser { timestamp = message.time, sequenceId = sequenceId, title = message.title ?: "", - message = message.message, + message = message.message ?: "", contentType = message.contentType ?: "", encoding = message.encoding ?: "", priority = toPriority(message.priority), diff --git a/app/src/main/java/io/heckel/ntfy/msg/Poller.kt b/app/src/main/java/io/heckel/ntfy/msg/Poller.kt index 1828b57c..5fe32111 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/Poller.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/Poller.kt @@ -60,7 +60,7 @@ class Poller( // Handle delete and read events latestBySequenceId - .filter { it.event == ApiService.EVENT_MESSAGE_READ || it.event == ApiService.EVENT_MESSAGE_DELETE } + .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) { @@ -68,7 +68,7 @@ class Poller( Log.d(TAG, "Deleting notifications with sequenceId $sequenceId") repository.markAsDeletedBySequenceId(subscriptionId, sequenceId) } - ApiService.EVENT_MESSAGE_READ -> { + ApiService.EVENT_MESSAGE_CLEAR -> { Log.d(TAG, "Marking notifications as read with sequenceId $sequenceId") repository.markAsReadBySequenceId(subscriptionId, sequenceId) } diff --git a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt index 960103fe..0325d5e2 100644 --- a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt +++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt @@ -329,7 +329,7 @@ class SubscriberService : Service() { // and the web app hooks.js:handleNotification(). when (notification.event) { - ApiService.EVENT_MESSAGE_READ -> { + ApiService.EVENT_MESSAGE_CLEAR -> { if (notification.sequenceId.isNotEmpty()) { repository.markAsReadBySequenceId(subscription.id, notification.sequenceId) } 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 9f30fb99..ff48a04c 100644 --- a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt +++ b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt @@ -51,7 +51,7 @@ class FirebaseService : FirebaseMessagingService() { when (data["event"]) { ApiService.EVENT_MESSAGE -> handleMessage(remoteMessage) ApiService.EVENT_MESSAGE_DELETE -> handleMessageDelete(remoteMessage) - ApiService.EVENT_MESSAGE_READ -> handleMessageRead(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}") @@ -108,11 +108,11 @@ class FirebaseService : FirebaseMessagingService() { } } - private fun handleMessageRead(remoteMessage: RemoteMessage) { + 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_read: from=${remoteMessage.from}, topic=$topic, sequenceId=$sequenceId") + Log.d(TAG, "Received message_clear: from=${remoteMessage.from}, topic=$topic, sequenceId=$sequenceId") CoroutineScope(job).launch { val baseUrl = getString(R.string.app_base_url) From 0923f63f3dbe5e260537d2ef78eeaddf1a5564a0 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Thu, 15 Jan 2026 19:06:28 -0500 Subject: [PATCH 15/21] Self-review --- app/src/main/java/io/heckel/ntfy/db/Repository.kt | 2 +- .../java/io/heckel/ntfy/msg/NotificationDispatcher.kt | 9 +++------ .../java/io/heckel/ntfy/service/SubscriberService.kt | 7 +++++-- .../play/java/io/heckel/ntfy/firebase/FirebaseService.kt | 6 ++++++ 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/io/heckel/ntfy/db/Repository.kt b/app/src/main/java/io/heckel/ntfy/db/Repository.kt index 0c280e97..25d00aa0 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Repository.kt @@ -159,7 +159,7 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database) } fun markAllAsRead(subscriptionId: Long) { - return notificationDao.markAllAsRead(subscriptionId) + notificationDao.markAllAsRead(subscriptionId) } fun markAsReadBySequenceId(subscriptionId: Long, sequenceId: String) { diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt index 1c309355..130b685f 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt @@ -83,10 +83,7 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { } private fun shouldNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean { - if (subscription.upAppId != null) { - return false - } - if (notification.event != ApiService.EVENT_MESSAGE) { + if (subscription.upAppId != null || notification.event != ApiService.EVENT_MESSAGE) { return false } val priority = if (notification.priority > 0) notification.priority else 3 @@ -99,10 +96,10 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { } private fun shouldBroadcast(subscription: Subscription, notification: Notification): Boolean { - if (subscription.upAppId != null) { // Never broadcast for UnifiedPush subscriptions + if (subscription.upAppId != null || notification.event != ApiService.EVENT_MESSAGE) { // Never broadcast for UnifiedPush subscriptions return false } - return repository.getBroadcastEnabled() && notification.event == ApiService.EVENT_MESSAGE + return repository.getBroadcastEnabled() } private fun shouldDistribute(subscription: Subscription, notification: Notification): Boolean { diff --git a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt index 0325d5e2..6d02320b 100644 --- a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt +++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt @@ -325,8 +325,11 @@ class SubscriberService : Service() { val url = topicUrl(subscription.baseUrl, subscription.topic) Log.d(TAG, "[$url] Received notification: $notification") GlobalScope.launch(Dispatchers.IO) { - // Note: This logic is duplicated in the FirebaseService::handleMessage() method - // and the web app hooks.js:handleNotification(). + // 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 -> { 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 ff48a04c..13fcbff3 100644 --- a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt +++ b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt @@ -47,6 +47,12 @@ 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) From 1eeabe21c9652d8ac71c647db9e094372d8da225 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Thu, 15 Jan 2026 20:43:50 -0500 Subject: [PATCH 16/21] Remove "notify" param --- app/src/main/java/io/heckel/ntfy/msg/Poller.kt | 10 +++------- app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt | 5 ++--- app/src/main/java/io/heckel/ntfy/work/PollWorker.kt | 7 +++---- .../java/io/heckel/ntfy/firebase/FirebaseService.kt | 3 --- 4 files changed, 8 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/io/heckel/ntfy/msg/Poller.kt b/app/src/main/java/io/heckel/ntfy/msg/Poller.kt index 5fe32111..6e69d27d 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/Poller.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/Poller.kt @@ -23,13 +23,11 @@ class Poller( * @param subscription The subscription to poll * @param user The user for authentication (may be null) * @param since The message ID to poll since (null for all cached messages) - * @param notify Whether to derive notification IDs for popup notifications */ suspend fun poll( subscription: Subscription, user: User?, - since: String? = null, - notify: Boolean = false + since: String? = null ): List { val notifications = api.poll( subscriptionId = subscription.id, @@ -38,7 +36,7 @@ class Poller( user = user, since = since ) - return processNotifications(subscription.id, notifications, notify) + return processNotifications(subscription.id, notifications) } /** @@ -48,8 +46,7 @@ class Poller( */ private suspend fun processNotifications( subscriptionId: Long, - notifications: List, - notify: Boolean + notifications: List ): List { // Group by sequenceId and only keep the latest notification for each sequence val latestBySequenceId = notifications @@ -78,7 +75,6 @@ class Poller( // Add only regular message notifications val notificationsToAdd = latestBySequenceId .filter { it.event == ApiService.EVENT_MESSAGE } - .map { if (notify) it.copy(notificationId = deriveNotificationId(it.sequenceId)) else it } val addedNotifications = mutableListOf() notificationsToAdd.forEach { notification -> if (repository.addNotification(notification)) { 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 230bc514..38546f58 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -730,14 +730,13 @@ 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 addedNotifications = poller.poll( subscription = subscription, user = user, - since = subscription.lastNotificationId, - notify = true + since = subscription.lastNotificationId ) newNotificationsCount += addedNotifications.size addedNotifications.forEach { notification -> 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 f122b015..eecc47a6 100644 --- a/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt @@ -41,13 +41,12 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, subscriptions.forEach{ subscription -> try { val user = repository.getUser(subscription.baseUrl) - val addedNotifications = poller.poll( + val newNotifications = poller.poll( subscription = subscription, user = user, - since = subscription.lastNotificationId, - notify = true + since = subscription.lastNotificationId ) - addedNotifications.forEach { notification -> + newNotifications.forEach { notification -> dispatcher.dispatch(subscription, notification) } } catch (e: Exception) { 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 13fcbff3..c74ec1d4 100644 --- a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt +++ b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt @@ -203,9 +203,6 @@ class FirebaseService : FirebaseMessagingService() { event = ApiService.EVENT_MESSAGE ) - // Note: This logic is duplicated in the SubscriberService::onNotificationReceived() method - // and the web app hooks.js:handleNotification(). - val added = repository.addNotification(notification) if (added) { Log.d(TAG, "Dispatching notification: from=${remoteMessage.from}, fcmprio=${remoteMessage.priority}, fcmprio_orig=${remoteMessage.originalPriority}, data=${data}") From 700abcb3caf94ec1efe8a1f81764662cb02332fa Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Thu, 15 Jan 2026 20:57:14 -0500 Subject: [PATCH 17/21] Change poller --- .../main/java/io/heckel/ntfy/msg/ApiService.kt | 9 +++++++-- app/src/main/java/io/heckel/ntfy/msg/Poller.kt | 18 ++---------------- .../java/io/heckel/ntfy/ui/DetailActivity.kt | 14 ++++---------- .../java/io/heckel/ntfy/ui/MainActivity.kt | 16 +++++----------- .../java/io/heckel/ntfy/work/PollWorker.kt | 7 +------ 5 files changed, 19 insertions(+), 45 deletions(-) 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 8970b4ef..759185d5 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt @@ -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 @@ -113,11 +114,15 @@ class ApiService(private val context: Context) { } } - suspend fun poll(subscriptionId: Long, baseUrl: String, topic: String, user: User?, since: String? = null): List { - val sinceVal = since ?: "all" + suspend fun poll(subscription: Subscription): List { + 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 -> diff --git a/app/src/main/java/io/heckel/ntfy/msg/Poller.kt b/app/src/main/java/io/heckel/ntfy/msg/Poller.kt index 6e69d27d..2c2799b0 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/Poller.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/Poller.kt @@ -3,9 +3,7 @@ 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.db.User import io.heckel.ntfy.util.Log -import io.heckel.ntfy.util.deriveNotificationId /** * Polls the server for notifications and updates the repository. @@ -21,21 +19,9 @@ class Poller( * Returns the list of new notifications that were added. * * @param subscription The subscription to poll - * @param user The user for authentication (may be null) - * @param since The message ID to poll since (null for all cached messages) */ - suspend fun poll( - subscription: Subscription, - user: User?, - since: String? = null - ): List { - val notifications = api.poll( - subscriptionId = subscription.id, - baseUrl = subscription.baseUrl, - topic = subscription.topic, - user = user, - since = since - ) + suspend fun poll(subscription: Subscription): List { + val notifications = api.poll(subscription) return processNotifications(subscription.id, notifications) } diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt index b9d13513..b0f50481 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -232,8 +232,7 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet // Fetch cached messages try { - val user = repository.getUser(subscription.baseUrl) // May be null - poller.poll(subscription, user) + poller.poll(subscription) } catch (e: Exception) { Log.e(TAG, "Unable to fetch notifications: ${e.message}", e) } @@ -723,16 +722,11 @@ 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 addedNotifications = poller.poll( - subscription = subscription, - user = user, - since = subscription.lastNotificationId - ) - val toastMessage = if (addedNotifications.isEmpty()) { + val newNotifications = poller.poll(subscription) + val toastMessage = if (newNotifications.isEmpty()) { getString(R.string.refresh_message_no_results) } else { - getString(R.string.refresh_message_result, addedNotifications.size) + getString(R.string.refresh_message_result, newNotifications.size) } runOnUiThread { Toast.makeText(this@DetailActivity, toastMessage, Toast.LENGTH_LONG).show() 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 38546f58..251a4ba0 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -691,9 +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 addedNotifications = poller.poll(subscription, user) - addedNotifications.forEach { notification -> + val notifications = poller.poll(subscription) + notifications.forEach { notification -> if (notification.icon != null) { DownloadManager.enqueue(this@MainActivity, notification.id, userAction = false, DownloadType.ICON) } @@ -732,14 +731,9 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific repository.getSubscriptions().forEach { subscription -> Log.d(TAG, "Polling subscription: $subscription") try { - val user = repository.getUser(subscription.baseUrl) // May be null - val addedNotifications = poller.poll( - subscription = subscription, - user = user, - since = subscription.lastNotificationId - ) - newNotificationsCount += addedNotifications.size - addedNotifications.forEach { notification -> + val newNotifications = poller.poll(subscription) + newNotificationsCount += newNotifications.size + newNotifications.forEach { notification -> dispatcher?.dispatch(subscription, notification) } } catch (e: Exception) { 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 eecc47a6..c3c18b2a 100644 --- a/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt @@ -40,12 +40,7 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, subscriptions.forEach{ subscription -> try { - val user = repository.getUser(subscription.baseUrl) - val newNotifications = poller.poll( - subscription = subscription, - user = user, - since = subscription.lastNotificationId - ) + val newNotifications = poller.poll(subscription) newNotifications.forEach { notification -> dispatcher.dispatch(subscription, notification) } From d97642437fdb7748ed5240c4239831e09ec4c467 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Thu, 15 Jan 2026 21:11:27 -0500 Subject: [PATCH 18/21] Changelog --- fastlane/metadata/android/en-US/changelog/NEXT.txt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/fastlane/metadata/android/en-US/changelog/NEXT.txt b/fastlane/metadata/android/en-US/changelog/NEXT.txt index 01ac5a1c..8d4e0556 100644 --- a/fastlane/metadata/android/en-US/changelog/NEXT.txt +++ b/fastlane/metadata/android/en-US/changelog/NEXT.txt @@ -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 From 850ba4550f2d5b769373f15ca64845a69afb44e9 Mon Sep 17 00:00:00 2001 From: Rogelio Dominguez Date: Fri, 16 Jan 2026 02:57:45 +0100 Subject: [PATCH 19/21] Translated using Weblate (Spanish) Currently translated at 86.8% (396 of 456 strings) Translation: ntfy/Android app Translate-URL: https://hosted.weblate.org/projects/ntfy/android/es/ --- app/src/main/res/values-es/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 2e96331c..866d998e 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -406,4 +406,7 @@ Administrar certificados Añadir certificados a la lista de confiados y administrar certificados de cliente para mTLS Certificados confiados + ej. https://ntfy.example.com + Subject + Error de conexión From d326d4daa51bcc7e691f5666fa5fed1cf57ce775 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Fri, 16 Jan 2026 02:46:19 +0100 Subject: [PATCH 20/21] Translated using Weblate (German) Currently translated at 96.4% (440 of 456 strings) Translation: ntfy/Android app Translate-URL: https://hosted.weblate.org/projects/ntfy/android/de/ --- app/src/main/res/values-de/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index ec3a9670..d99a6d87 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -447,4 +447,7 @@ Ungültige PKCS#12-Datei Zertifikatsdetails Sicherheitswarnung + Füge vertrauenswürdiges Zertifikat hinzu + 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. + Du hast ein Zertifikat ausgewählt. Überprüfe die untenstehenden Details, bevor du es hinzufügst. From 38a889069e9dece7cd7d48001b9d971e2e8a11af Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Fri, 16 Jan 2026 03:14:20 +0100 Subject: [PATCH 21/21] Translated using Weblate (German) Currently translated at 99.1% (452 of 456 strings) Translation: ntfy/Android app Translate-URL: https://hosted.weblate.org/projects/ntfy/android/de/ --- app/src/main/res/values-de/strings.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index d99a6d87..41cf597d 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -450,4 +450,16 @@ Füge vertrauenswürdiges Zertifikat hinzu 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. Du hast ein Zertifikat ausgewählt. Überprüfe die untenstehenden Details, bevor du es hinzufügst. + Deine Verbindung ist nicht privat + Achtung: Dieses Zertifikat ist abgelaufen. + Achtung: Dieses Zertifikat ist noch nicht gültig. + Unglültige URL + Das Zertifikat konnte nicht geladen werden: %1$s + Vertrauen + Client-Zertifikat + Füge Client-Zertifikat hinzu + Passwort + Falsches Passwort oder ungültige PKCS#12-Datei + Ungültiges Passwort oder fehlerhafte PKCS#12-Datei + Ungültige Service-URL