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..1bfa0e9a --- /dev/null +++ b/app/schemas/io.heckel.ntfy.db.Database/18.json @@ -0,0 +1,429 @@ +{ + "formatVersion": 1, + "database": { + "version": 18, + "identityHash": "02663facc6503d5ea7015397d5e8cc94", + "entities": [ + { + "tableName": "Subscription", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, `minPriority` INTEGER NOT NULL, `autoDelete` INTEGER NOT NULL, `insistent` INTEGER NOT NULL, `lastNotificationId` TEXT, `icon` TEXT, `upAppId` TEXT, `upConnectorToken` TEXT, `displayName` TEXT, `dedicatedChannels` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "topic", + "columnName": "topic", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "instant", + "columnName": "instant", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mutedUntil", + "columnName": "mutedUntil", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minPriority", + "columnName": "minPriority", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "autoDelete", + "columnName": "autoDelete", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "insistent", + "columnName": "insistent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT" + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT" + }, + { + "fieldPath": "upAppId", + "columnName": "upAppId", + "affinity": "TEXT" + }, + { + "fieldPath": "upConnectorToken", + "columnName": "upConnectorToken", + "affinity": "TEXT" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "dedicatedChannels", + "columnName": "dedicatedChannels", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Subscription_baseUrl_topic", + "unique": true, + "columnNames": [ + "baseUrl", + "topic" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_baseUrl_topic` ON `${TABLE_NAME}` (`baseUrl`, `topic`)" + }, + { + "name": "index_Subscription_upConnectorToken", + "unique": true, + "columnNames": [ + "upConnectorToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_upConnectorToken` ON `${TABLE_NAME}` (`upConnectorToken`)" + } + ] + }, + { + "tableName": "Notification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `sequenceId` TEXT NOT NULL, `title` TEXT NOT NULL, `message` TEXT NOT NULL, `contentType` TEXT NOT NULL, `encoding` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `actions` TEXT, `deleted` INTEGER NOT NULL, `icon_url` TEXT, `icon_contentUri` TEXT, `attachment_name` TEXT, `attachment_type` TEXT, `attachment_size` INTEGER, `attachment_expires` INTEGER, `attachment_url` TEXT, `attachment_contentUri` TEXT, `attachment_progress` INTEGER, PRIMARY KEY(`id`, `subscriptionId`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sequenceId", + "columnName": "sequenceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encoding", + "columnName": "encoding", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "3" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "click", + "columnName": "click", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actions", + "columnName": "actions", + "affinity": "TEXT" + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon.url", + "columnName": "icon_url", + "affinity": "TEXT" + }, + { + "fieldPath": "icon.contentUri", + "columnName": "icon_contentUri", + "affinity": "TEXT" + }, + { + "fieldPath": "attachment.name", + "columnName": "attachment_name", + "affinity": "TEXT" + }, + { + "fieldPath": "attachment.type", + "columnName": "attachment_type", + "affinity": "TEXT" + }, + { + "fieldPath": "attachment.size", + "columnName": "attachment_size", + "affinity": "INTEGER" + }, + { + "fieldPath": "attachment.expires", + "columnName": "attachment_expires", + "affinity": "INTEGER" + }, + { + "fieldPath": "attachment.url", + "columnName": "attachment_url", + "affinity": "TEXT" + }, + { + "fieldPath": "attachment.contentUri", + "columnName": "attachment_contentUri", + "affinity": "TEXT" + }, + { + "fieldPath": "attachment.progress", + "columnName": "attachment_progress", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "subscriptionId" + ] + } + }, + { + "tableName": "User", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`baseUrl`))", + "fields": [ + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "baseUrl" + ] + } + }, + { + "tableName": "Log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `tag` TEXT NOT NULL, `level` INTEGER NOT NULL, `message` TEXT NOT NULL, `exception` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "exception", + "columnName": "exception", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "CustomHeader", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `name` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`baseUrl`, `name`))", + "fields": [ + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "baseUrl", + "name" + ] + } + }, + { + "tableName": "TrustedCertificate", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `pem` TEXT NOT NULL, PRIMARY KEY(`baseUrl`))", + "fields": [ + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pem", + "columnName": "pem", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "baseUrl" + ] + } + }, + { + "tableName": "ClientCertificate", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `p12Base64` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`baseUrl`))", + "fields": [ + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "p12Base64", + "columnName": "p12Base64", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "baseUrl" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '02663facc6503d5ea7015397d5e8cc94')" + ] + } +} \ 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/backup/Backuper.kt b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt index 8ab61e8a..9be9bc37 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 @@ -187,6 +188,7 @@ class Backuper(val context: Context) { id = n.id, subscriptionId = n.subscriptionId, timestamp = n.timestamp, + sequenceId = n.sequenceId ?: n.id, title = n.title, message = n.message, contentType = n.contentType, @@ -343,6 +345,7 @@ class Backuper(val context: Context) { id = n.id, subscriptionId = n.subscriptionId, timestamp = n.timestamp, + sequenceId = n.sequenceId, title = n.title, message = n.message, contentType = n.contentType, @@ -440,6 +443,7 @@ data class Notification( val id: String, val subscriptionId: Long, val timestamp: Long, + @SerializedName("sequence_id") val sequenceId: String?, // Sequence ID for updating notifications val title: String, val message: String, val contentType: String, // "" or "text/markdown" (empty assumes "text/plain") 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 781e7ba7..57fa984f 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 @@ -144,7 +145,8 @@ data class SubscriptionWithMetadata( data class Notification( @ColumnInfo(name = "id") val id: String, @ColumnInfo(name = "subscriptionId") val subscriptionId: Long, - @ColumnInfo(name = "timestamp") val timestamp: Long, // Unix timestamp + @ColumnInfo(name = "timestamp") val timestamp: Long, // Unix timestamp in seconds + @ColumnInfo(name = "sequenceId") val sequenceId: String, // Sequence ID for updating notifications @ColumnInfo(name = "title") val title: String, @ColumnInfo(name = "message") val message: String, @ColumnInfo(name = "contentType") val contentType: String, // "" or "text/markdown" (empty assume text/plain) @@ -157,7 +159,30 @@ data class Notification( @ColumnInfo(name = "actions") val actions: List?, @Embedded(prefix = "attachment_") val attachment: Attachment?, @ColumnInfo(name = "deleted") val deleted: Boolean, -) + @Ignore val event: String = ApiService.EVENT_MESSAGE, // In-memory event type (message, message_delete, message_clear) +) { + constructor( + id: String, + subscriptionId: Long, + timestamp: Long, + sequenceId: String, + title: String, + message: String, + contentType: String, + encoding: String, + notificationId: Int, + priority: Int, + tags: String, + click: String, + icon: Icon?, + actions: List?, + attachment: Attachment?, + deleted: Boolean + ) : this( + id, subscriptionId, timestamp, sequenceId, title, message, contentType, encoding, + notificationId, priority, tags, click, icon, actions, attachment, deleted, event = ApiService.EVENT_MESSAGE + ) +} fun Notification.isMarkdown(): Boolean { return contentType == "text/markdown" @@ -272,7 +297,7 @@ data class LogEntry( } @androidx.room.Database( - version = 17, + version = 18, entities = [ Subscription::class, Notification::class, @@ -317,6 +342,7 @@ abstract class Database : RoomDatabase() { .addMigrations(MIGRATION_14_15) .addMigrations(MIGRATION_15_16) .addMigrations(MIGRATION_16_17) + .addMigrations(MIGRATION_17_18) .fallbackToDestructiveMigration(true) .build() this.instance = instance @@ -450,6 +476,13 @@ abstract class Database : RoomDatabase() { db.execSQL("UPDATE Notification SET icon_contentUri = NULL WHERE icon_url IS NULL AND icon_contentUri IS NOT NULL") } } + + private val MIGRATION_17_18 = object : Migration(17, 18) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE Notification ADD COLUMN sequenceId TEXT NOT NULL DEFAULT ''") + db.execSQL("UPDATE Notification SET sequenceId = id WHERE sequenceId = ''") + } + } } } @@ -541,9 +574,6 @@ interface NotificationDao { @Query("SELECT * FROM notification WHERE subscriptionId = :subscriptionId AND deleted != 1 ORDER BY timestamp DESC") fun listFlow(subscriptionId: Long): Flow> - @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 @@ -563,11 +593,17 @@ interface NotificationDao { fun get(notificationId: String): Notification? @Query("UPDATE notification SET notificationId = 0 WHERE subscriptionId = :subscriptionId") - fun clearAllNotificationIds(subscriptionId: Long) + fun markAllAsRead(subscriptionId: Long) + + @Query("UPDATE notification SET notificationId = 0 WHERE subscriptionId = :subscriptionId AND sequenceId = :sequenceId") + fun markAsReadBySequenceId(subscriptionId: Long, sequenceId: String) @Query("UPDATE notification SET deleted = 1 WHERE id = :notificationId") fun markAsDeleted(notificationId: String) + @Query("UPDATE notification SET deleted = 1 WHERE subscriptionId = :subscriptionId AND sequenceId = :sequenceId") + fun markAsDeletedBySequenceId(subscriptionId: Long, sequenceId: String) + @Query("UPDATE notification SET deleted = 1 WHERE subscriptionId = :subscriptionId") fun markAllAsDeleted(subscriptionId: Long) 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 9c195b4c..25d00aa0 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 @@ -117,26 +118,21 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database) return notificationDao.listFlow(subscriptionId).asLiveData() } - fun clearAllNotificationIds(subscriptionId: Long) { - return notificationDao.clearAllNotificationIds(subscriptionId) - } - fun getNotification(notificationId: String): Notification? { return notificationDao.get(notificationId) } - fun onlyNewNotifications(subscriptionId: Long, notifications: List): List { - val existingIds = notificationDao.listIds(subscriptionId) - return notifications.filterNot { existingIds.contains(it.id) } - } - @Suppress("RedundantSuspendModifier") @WorkerThread suspend fun addNotification(notification: Notification): Boolean { val maybeExistingNotification = notificationDao.get(notification.id) - if (maybeExistingNotification != null) { + if (maybeExistingNotification != null || notification.event != ApiService.EVENT_MESSAGE) { return false } + // Mark old notifications with the same sequence ID as deleted (this is an update to an existing sequence) + if (notification.sequenceId.isNotEmpty()) { + notificationDao.markAsDeletedBySequenceId(notification.subscriptionId, notification.sequenceId) + } subscriptionDao.updateLastNotificationId(notification.subscriptionId, notification.id) notificationDao.add(notification) return true @@ -154,10 +150,22 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database) notificationDao.markAsDeleted(notificationId) } + fun markAsDeletedBySequenceId(subscriptionId: Long, sequenceId: String) { + notificationDao.markAsDeletedBySequenceId(subscriptionId, sequenceId) + } + fun markAllAsDeleted(subscriptionId: Long) { notificationDao.markAllAsDeleted(subscriptionId) } + fun markAllAsRead(subscriptionId: Long) { + notificationDao.markAllAsRead(subscriptionId) + } + + fun markAsReadBySequenceId(subscriptionId: Long, sequenceId: String) { + notificationDao.markAsReadBySequenceId(subscriptionId, sequenceId) + } + fun markAsDeletedIfOlderThan(subscriptionId: Long, olderThanTimestamp: Long) { notificationDao.markAsDeletedIfOlderThan(subscriptionId, olderThanTimestamp) } 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 6c93dc97..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 @@ -20,7 +21,6 @@ import okhttp3.RequestBody.Companion.toRequestBody import okio.BufferedSource import java.io.IOException import java.net.URLEncoder -import kotlin.random.Random class ApiService(private val context: Context) { private val repository = Repository.getInstance(context) @@ -114,11 +114,15 @@ class ApiService(private val context: Context) { } } - suspend fun poll(subscriptionId: Long, baseUrl: String, topic: String, user: User?, since: String? = null): List { - 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 -> @@ -128,7 +132,7 @@ class ApiService(private val context: Context) { val body = response.body.string().trim() if (body.isEmpty()) return emptyList() val notifications = body.lines().mapNotNull { line -> - parser.parse(line, subscriptionId = subscriptionId, notificationId = 0) // No notification when we poll + parser.parse(line, subscriptionId = subscriptionId) } Log.d(TAG, "Notifications: $notifications") @@ -156,7 +160,7 @@ class ApiService(private val context: Context) { if (code == 401 || code == 403) { throw NotAuthorizedException(code, message) } - throw IOException("Unexpected response $code when subscribing to $url") + throw IOException("Unexpected response $code when subscribing") } return Pair(call, response.body.source()) } @@ -198,6 +202,8 @@ class ApiService(private val context: Context) { // These constants have corresponding values in the server codebase! const val CONTROL_TOPIC = "~control" const val EVENT_MESSAGE = "message" + const val EVENT_MESSAGE_DELETE = "message_delete" + const val EVENT_MESSAGE_CLEAR = "message_clear" const val EVENT_KEEPALIVE = "keepalive" const val EVENT_POLL_REQUEST = "poll_request" } 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 68642a01..10c958fd 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/Message.kt b/app/src/main/java/io/heckel/ntfy/msg/Message.kt index b34965f2..8aa62b5f 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, + @SerializedName("sequence_id") val sequenceId: String?, // Sequence ID for updating notifications val event: String, val topic: String, val priority: Int?, @@ -17,7 +18,7 @@ data class Message( val icon: String?, val actions: List?, 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 f77c9b0b..130b685f 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt @@ -4,8 +4,8 @@ import android.content.Context import io.heckel.ntfy.db.Notification import io.heckel.ntfy.db.Repository import io.heckel.ntfy.db.Subscription -import io.heckel.ntfy.util.Log import io.heckel.ntfy.up.Distributor +import io.heckel.ntfy.util.Log import io.heckel.ntfy.util.decodeBytesMessage import io.heckel.ntfy.util.safeLet @@ -25,13 +25,16 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { fun dispatch(subscription: Subscription, notification: Notification) { Log.d(TAG, "Dispatching $notification for subscription $subscription") + val cancel = shouldCancel(notification) val muted = getMuted(subscription) val notify = shouldNotify(subscription, notification, muted) - val broadcast = shouldBroadcast(subscription) - val distribute = shouldDistribute(subscription) + val broadcast = shouldBroadcast(subscription, notification) + val distribute = shouldDistribute(subscription, notification) val downloadAttachment = shouldDownloadAttachment(notification) val downloadIcon = shouldDownloadIcon(notification) - if (notify) { + if (cancel) { + notifier.cancel(notification.notificationId) + } else if (notify) { notifier.display(subscription, notification) } if (broadcast) { @@ -52,7 +55,7 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { } private fun shouldDownloadAttachment(notification: Notification): Boolean { - if (notification.attachment == null) { + if (notification.attachment == null || notification.event != ApiService.EVENT_MESSAGE) { return false } val attachment = notification.attachment @@ -72,11 +75,15 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { } } private fun shouldDownloadIcon(notification: Notification): Boolean { - return notification.icon?.hasValidUrl() == true + return notification.icon?.hasValidUrl() == true && notification.event == ApiService.EVENT_MESSAGE + } + + private fun shouldCancel(notification: Notification): Boolean { + return notification.event == ApiService.EVENT_MESSAGE_CLEAR || notification.event == ApiService.EVENT_MESSAGE_DELETE } private fun shouldNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean { - if (subscription.upAppId != null) { + if (subscription.upAppId != null || notification.event != ApiService.EVENT_MESSAGE) { return false } val priority = if (notification.priority > 0) notification.priority else 3 @@ -88,15 +95,15 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { return !detailsVisible && !muted } - private fun shouldBroadcast(subscription: Subscription): Boolean { - if (subscription.upAppId != null) { // Never broadcast for UnifiedPush subscriptions + private fun shouldBroadcast(subscription: Subscription, notification: Notification): Boolean { + if (subscription.upAppId != null || notification.event != ApiService.EVENT_MESSAGE) { // Never broadcast for UnifiedPush subscriptions return false } return repository.getBroadcastEnabled() } - private fun shouldDistribute(subscription: Subscription): Boolean { - return subscription.upAppId != null // Only distribute for UnifiedPush subscriptions + private fun shouldDistribute(subscription: Subscription, notification: Notification): Boolean { + return subscription.upAppId != null && notification.event == ApiService.EVENT_MESSAGE // Only distribute for UnifiedPush subscriptions } private fun getMuted(subscription: Subscription): Boolean { 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..0eb11808 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,14 +14,17 @@ import java.lang.reflect.Type class NotificationParser { private val gson = Gson() - fun parse(s: String, subscriptionId: Long = 0, notificationId: Int = 0): Notification? { - val notificationWithTopic = parseWithTopic(s, subscriptionId = subscriptionId, notificationId = notificationId) + fun parse(s: String, subscriptionId: Long = 0): Notification? { + val notificationWithTopic = parseWithTopic(s, subscriptionId = subscriptionId) return notificationWithTopic?.notification } - fun parseWithTopic(s: String, subscriptionId: Long = 0, notificationId: Int = 0): NotificationWithTopic? { + fun parseWithTopic(s: String, subscriptionId: Long = 0): NotificationWithTopic? { val message = gson.fromJson(s, Message::class.java) - if (message.event != ApiService.EVENT_MESSAGE) { + val validEvent = message.event == ApiService.EVENT_MESSAGE || + message.event == ApiService.EVENT_MESSAGE_DELETE || + message.event == ApiService.EVENT_MESSAGE_CLEAR + if (!validEvent) { return null } val attachment = if (message.attachment?.url != null) { @@ -32,31 +36,31 @@ 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 sequenceId = message.sequenceId ?: message.id // Default to id if sequenceId not provided val notification = Notification( id = message.id, subscriptionId = subscriptionId, timestamp = message.time, + sequenceId = sequenceId, title = message.title ?: "", - message = message.message, + message = message.message ?: "", contentType = message.contentType ?: "", encoding = message.encoding ?: "", priority = toPriority(message.priority), @@ -65,8 +69,9 @@ class NotificationParser { icon = icon, actions = actions, attachment = attachment, - notificationId = notificationId, - deleted = false + notificationId = deriveNotificationId(sequenceId), + deleted = false, + event = message.event ) return NotificationWithTopic(message.topic, notification) } 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..2c2799b0 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/msg/Poller.kt @@ -0,0 +1,77 @@ +package io.heckel.ntfy.msg + +import io.heckel.ntfy.db.Notification +import io.heckel.ntfy.db.Repository +import io.heckel.ntfy.db.Subscription +import io.heckel.ntfy.util.Log + +/** + * Polls the server for notifications and updates the repository. + * Groups notifications by sequenceId and only keeps the latest for each sequence. + * Deletes sequences where the latest notification is marked as deleted. + */ +class Poller( + private val api: ApiService, + private val repository: Repository +) { + /** + * Polls for notifications and updates the repository. + * Returns the list of new notifications that were added. + * + * @param subscription The subscription to poll + */ + suspend fun poll(subscription: Subscription): List { + val notifications = api.poll(subscription) + return processNotifications(subscription.id, notifications) + } + + /** + * Processes a list of notifications: groups by sequenceId, deletes deleted sequences, + * and adds only non-deleted latest notifications. + * Returns the list of notifications that were added. + */ + private suspend fun processNotifications( + subscriptionId: Long, + notifications: List + ): List { + // Group by sequenceId and only keep the latest notification for each sequence + val latestBySequenceId = notifications + .groupBy { it.sequenceId.ifEmpty { it.id } } + .mapValues { (_, notifs) -> notifs.maxByOrNull { it.timestamp } } + .values + .filterNotNull() + + // Handle delete and read events + latestBySequenceId + .filter { it.event == ApiService.EVENT_MESSAGE_CLEAR || it.event == ApiService.EVENT_MESSAGE_DELETE } + .forEach { notification -> + val sequenceId = notification.sequenceId.ifEmpty { notification.id } + when (notification.event) { + ApiService.EVENT_MESSAGE_DELETE -> { + Log.d(TAG, "Deleting notifications with sequenceId $sequenceId") + repository.markAsDeletedBySequenceId(subscriptionId, sequenceId) + } + ApiService.EVENT_MESSAGE_CLEAR -> { + Log.d(TAG, "Marking notifications as read with sequenceId $sequenceId") + repository.markAsReadBySequenceId(subscriptionId, sequenceId) + } + } + } + + // Add only regular message notifications + val notificationsToAdd = latestBySequenceId + .filter { it.event == ApiService.EVENT_MESSAGE } + val addedNotifications = mutableListOf() + 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/JsonConnection.kt b/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt index bfc6ee2a..8b3c2ca4 100644 --- a/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt +++ b/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt @@ -16,7 +16,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import okhttp3.Call -import kotlin.random.Random class JsonConnection( private val connectionId: ConnectionId, @@ -58,7 +57,7 @@ class JsonConnection( // Blocking read loop: reads JSON lines until connection closes or is cancelled while (isActive && serviceActive() && !source.exhausted()) { val line = source.readUtf8Line() ?: break - val notificationWithTopic = parser.parseWithTopic(line, notificationId = Random.nextInt(), subscriptionId = 0) + val notificationWithTopic = parser.parseWithTopic(line, subscriptionId = 0) if (notificationWithTopic != null) { since = notificationWithTopic.notification.id val topic = notificationWithTopic.topic 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 397b5506..6d02320b 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 @@ -319,15 +325,33 @@ class SubscriberService : Service() { val url = topicUrl(subscription.baseUrl, subscription.topic) Log.d(TAG, "[$url] Received notification: $notification") GlobalScope.launch(Dispatchers.IO) { - if (repository.addNotification(notification)) { - Log.d(TAG, "[$url] Dispatching notification $notification") - dispatcher.dispatch(subscription, notification) - } - wakeLock?.let { - if (it.isHeld) { - it.release() + // This logic is (partially) duplicated in + // - Android: SubscriberService::onNotificationReceived() + // - Android: FirebaseService::onMessageReceived() + // - Web app: hooks.js:handleNotification() + // - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ... + + when (notification.event) { + ApiService.EVENT_MESSAGE_CLEAR -> { + if (notification.sequenceId.isNotEmpty()) { + repository.markAsReadBySequenceId(subscription.id, notification.sequenceId) + } + dispatcher.dispatch(subscription, notification) + } + 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/main/java/io/heckel/ntfy/service/WsConnection.kt b/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt index 61223474..9ffbdcbf 100644 --- a/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt +++ b/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt @@ -21,7 +21,6 @@ import java.net.ProtocolException import java.util.Calendar import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference -import kotlin.random.Random /** * Connect to ntfy server via WebSockets. This connection represents a single connection to a server, with @@ -148,7 +147,7 @@ class WsConnection( override fun onMessage(webSocket: WebSocket, text: String) { synchronize("onMessage") { Log.d(TAG, "$shortUrl (gid=$globalId, lid=$id): Received message: $text") - val notificationWithTopic = parser.parseWithTopic(text, subscriptionId = 0, notificationId = Random.nextInt()) + val notificationWithTopic = parser.parseWithTopic(text, subscriptionId = 0) if (notificationWithTopic == null) { Log.d(TAG, "$shortUrl (gid=$globalId, lid=$id): Irrelevant or unknown message. Discarding.") return@synchronize 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 d0a19521..b0f50481 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 @@ -230,9 +232,7 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet // Fetch cached messages try { - val user = repository.getUser(subscription.baseUrl) // May be null - val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic, user) - notifications.forEach { notification -> repository.addNotification(notification) } + poller.poll(subscription) } catch (e: Exception) { Log.e(TAG, "Unable to fetch notifications: ${e.message}", e) } @@ -337,6 +337,7 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet val snackbar = Snackbar.make(mainList, R.string.detail_item_snack_deleted, Snackbar.LENGTH_SHORT) snackbar.setAction(R.string.detail_item_snack_undo) { lifecycleScope.launch(Dispatchers.IO) { + // Note: undo only restores the latest notification, not the entire sequence repository.undeleteNotification(notification.id) } } @@ -524,7 +525,7 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet GlobalScope.launch(Dispatchers.IO) { // Note: This is here and not in onDestroy/onStop, because we want to clear notifications as early // as possible, so that we don't see the "new" bubble in the main list anymore. - repository.clearAllNotificationIds(subscriptionId) + repository.markAllAsRead(subscriptionId) } Log.d(TAG, "onPause hook: Marking subscription $subscriptionId as 'not open'") repository.detailViewSubscriptionId.set(0) // Mark as closed @@ -721,15 +722,12 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet lifecycleScope.launch(Dispatchers.IO) { try { val subscription = repository.getSubscription(subscriptionId) ?: return@launch - val user = repository.getUser(subscription.baseUrl) // May be null - val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic, user, subscription.lastNotificationId) - val newNotifications = repository.onlyNewNotifications(subscriptionId, notifications) + val newNotifications = poller.poll(subscription) val toastMessage = if (newNotifications.isEmpty()) { getString(R.string.refresh_message_no_results) } else { getString(R.string.refresh_message_result, newNotifications.size) } - newNotifications.forEach { notification -> repository.addNotification(notification) } runOnUiThread { Toast.makeText(this@DetailActivity, toastMessage, Toast.LENGTH_LONG).show() mainListContainer.isRefreshing = false 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 b99d46b6..251a4ba0 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 @@ -73,7 +74,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.Date import java.util.concurrent.TimeUnit -import kotlin.random.Random import androidx.core.view.size import androidx.core.view.get import androidx.core.net.toUri @@ -84,6 +84,7 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific } private val repository by lazy { (application as Application).repository } private val api by lazy { ApiService(this) } + private val poller by lazy { Poller(api, repository) } private val messenger = FirebaseMessenger() // UI elements @@ -690,10 +691,8 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific // Fetch cached messages lifecycleScope.launch(Dispatchers.IO) { try { - val user = repository.getUser(subscription.baseUrl) // May be null - val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic, user) + val notifications = poller.poll(subscription) notifications.forEach { notification -> - repository.addNotification(notification) if (notification.icon != null) { DownloadManager.enqueue(this@MainActivity, notification.id, userAction = false, DownloadType.ICON) } @@ -730,17 +729,12 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific var errorMessage = "" // First error var newNotificationsCount = 0 repository.getSubscriptions().forEach { subscription -> - Log.d(TAG, "subscription: $subscription") + Log.d(TAG, "Polling subscription: $subscription") try { - val user = repository.getUser(subscription.baseUrl) // May be null - val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic, user, subscription.lastNotificationId) - val newNotifications = repository.onlyNewNotifications(subscription.id, notifications) + val newNotifications = poller.poll(subscription) + newNotificationsCount += newNotifications.size newNotifications.forEach { notification -> - newNotificationsCount++ - val notificationWithId = notification.copy(notificationId = Random.nextInt()) - if (repository.addNotification(notificationWithId)) { - dispatcher?.dispatch(subscription, notificationWithId) - } + dispatcher?.dispatch(subscription, notification) } } catch (e: Exception) { val topic = displayName(appBaseUrl, subscription) @@ -789,7 +783,7 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific private fun handleActionModeClick(subscription: Subscription) { adapter.toggleSelection(subscription.id) - if (adapter.selected.size == 0) { + if (adapter.selected.isEmpty()) { finishActionMode() } else { actionMode!!.title = adapter.selected.size.toString() 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 31a34879..309a5a51 100644 --- a/app/src/main/java/io/heckel/ntfy/util/Util.kt +++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt @@ -511,3 +511,13 @@ fun Button.dangerButton() { fun Long.nullIfZero(): Long? { return if (this == 0L) return null else this } + +/** + * Derives a stable notification ID from a string (typically the sequenceId or id). + * This allows Android to update existing notifications when a new version arrives. + * The result is always positive and never zero (0 means "no notification"). + */ +fun deriveNotificationId(sequenceId: String): Int { + val hash = sequenceId.hashCode() + return if (hash == 0 || hash == Int.MIN_VALUE) 1 else abs(hash) +} 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..c3c18b2a 100644 --- a/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt @@ -7,10 +7,10 @@ import io.heckel.ntfy.BuildConfig import io.heckel.ntfy.db.Repository import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.NotificationDispatcher +import io.heckel.ntfy.msg.Poller import io.heckel.ntfy.util.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import kotlin.random.Random class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) { // IMPORTANT: @@ -27,6 +27,7 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, val repository = Repository.getInstance(applicationContext) val dispatcher = NotificationDispatcher(applicationContext, repository) val api = ApiService(applicationContext) + val poller = Poller(api, repository) val baseUrl = inputData.getString(INPUT_DATA_BASE_URL) val topic = inputData.getString(INPUT_DATA_TOPIC) @@ -39,21 +40,9 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, subscriptions.forEach{ subscription -> try { - val user = repository.getUser(subscription.baseUrl) - val notifications = api.poll( - subscriptionId = subscription.id, - baseUrl = subscription.baseUrl, - topic = subscription.topic, - user = user, - since = subscription.lastNotificationId - ) - val newNotifications = repository - .onlyNewNotifications(subscription.id, notifications) - .map { it.copy(notificationId = Random.nextInt()) } + val newNotifications = poller.poll(subscription) newNotifications.forEach { notification -> - if (repository.addNotification(notification)) { - dispatcher.dispatch(subscription, notification) - } + dispatcher.dispatch(subscription, notification) } } catch (e: Exception) { Log.e(TAG, "Failed checking messages: ${e.message}", e) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index ec3a9670..41cf597d 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -447,4 +447,19 @@ 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. + 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 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 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..c74ec1d4 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,13 @@ import io.heckel.ntfy.app.Application import io.heckel.ntfy.db.Attachment import io.heckel.ntfy.db.Icon import io.heckel.ntfy.db.Notification -import io.heckel.ntfy.util.Log import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.NotificationDispatcher import io.heckel.ntfy.msg.NotificationParser +import io.heckel.ntfy.msg.NotificationService import io.heckel.ntfy.service.SubscriberService +import io.heckel.ntfy.util.Log +import io.heckel.ntfy.util.deriveNotificationId import io.heckel.ntfy.util.nullIfZero import io.heckel.ntfy.util.toPriority import io.heckel.ntfy.util.topicShortUrl @@ -21,7 +28,6 @@ import io.heckel.ntfy.work.PollWorker import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch -import kotlin.random.Random class FirebaseService : FirebaseMessagingService() { private val repository by lazy { (application as Application).repository } @@ -41,9 +47,17 @@ class FirebaseService : FirebaseMessagingService() { } // Dispatch event + // + // This logic is (partially) duplicated in + // - Android: SubscriberService::onNotificationReceived() + // - Android: FirebaseService::onMessageReceived() + // - Web app: hooks.js:handleNotification() + // - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ... val data = remoteMessage.data when (data["event"]) { ApiService.EVENT_MESSAGE -> handleMessage(remoteMessage) + ApiService.EVENT_MESSAGE_DELETE -> handleMessageDelete(remoteMessage) + ApiService.EVENT_MESSAGE_CLEAR -> handleMessageClear(remoteMessage) ApiService.EVENT_KEEPALIVE -> handleKeepalive(remoteMessage) ApiService.EVENT_POLL_REQUEST -> handlePollRequest(remoteMessage) else -> Log.d(TAG, "Discarding unexpected message (2): from=${remoteMessage.from}, data=${data}") @@ -80,6 +94,46 @@ class FirebaseService : FirebaseMessagingService() { workManager.enqueueUniqueWork(workName, ExistingWorkPolicy.REPLACE, workRequest) } + private fun handleMessageDelete(remoteMessage: RemoteMessage) { + val data = remoteMessage.data + val topic = data["topic"] ?: return + val sequenceId = data["sequence_id"] ?: return + Log.d(TAG, "Received message_delete: from=${remoteMessage.from}, topic=$topic, sequenceId=$sequenceId") + + CoroutineScope(job).launch { + val baseUrl = getString(R.string.app_base_url) + val subscription = repository.getSubscription(baseUrl, topic) ?: return@launch + + // Mark all notifications with this sequenceId as deleted + repository.markAsDeletedBySequenceId(subscription.id, sequenceId) + + // Cancel the Android notification + val notificationId = deriveNotificationId(sequenceId) + val notifier = NotificationService(this@FirebaseService) + notifier.cancel(notificationId) + } + } + + private fun handleMessageClear(remoteMessage: RemoteMessage) { + val data = remoteMessage.data + val topic = data["topic"] ?: return + val sequenceId = data["sequence_id"] ?: return + Log.d(TAG, "Received message_clear: from=${remoteMessage.from}, topic=$topic, sequenceId=$sequenceId") + + CoroutineScope(job).launch { + val baseUrl = getString(R.string.app_base_url) + val subscription = repository.getSubscription(baseUrl, topic) ?: return@launch + + // Mark all notifications with this sequenceId as read + repository.markAsReadBySequenceId(subscription.id, sequenceId) + + // Cancel the Android notification + val notificationId = deriveNotificationId(sequenceId) + val notifier = NotificationService(this@FirebaseService) + notifier.cancel(notificationId) + } + } + private fun handleMessage(remoteMessage: RemoteMessage) { val data = remoteMessage.data val id = data["id"] @@ -99,6 +153,7 @@ class FirebaseService : FirebaseMessagingService() { val attachmentSize = data["attachment_size"]?.toLongOrNull()?.nullIfZero() val attachmentExpires = data["attachment_expires"]?.toLongOrNull()?.nullIfZero() val attachmentUrl = data["attachment_url"] + val sequenceId = data["sequence_id"] val truncated = (data["truncated"] ?: "") == "1" if (id == null || topic == null || message == null || timestamp == null) { Log.d(TAG, "Discarding unexpected message: from=${remoteMessage.from}, fcmprio=${remoteMessage.priority}, fcmprio_orig=${remoteMessage.originalPriority}, data=${data}") @@ -127,10 +182,12 @@ class FirebaseService : FirebaseMessagingService() { ) } else null val icon: Icon? = if (iconUrl != null && iconUrl != "") Icon(url = iconUrl) else null + val actualSequenceId = sequenceId ?: id val notification = Notification( id = id, subscriptionId = subscription.id, timestamp = timestamp, + sequenceId = actualSequenceId, title = title ?: "", message = message, contentType = contentType ?: "", @@ -141,10 +198,13 @@ class FirebaseService : FirebaseMessagingService() { icon = icon, actions = parser.parseActions(actions), attachment = attachment, - notificationId = Random.nextInt(), - deleted = false + notificationId = deriveNotificationId(actualSequenceId), + deleted = false, + event = ApiService.EVENT_MESSAGE ) - if (repository.addNotification(notification)) { + + val added = repository.addNotification(notification) + if (added) { Log.d(TAG, "Dispatching notification: from=${remoteMessage.from}, fcmprio=${remoteMessage.priority}, fcmprio_orig=${remoteMessage.originalPriority}, data=${data}") dispatcher.dispatch(subscription, notification) } 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