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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,6 +6,7 @@ import io.heckel.ntfy.db.Action
import io.heckel.ntfy.db.Attachment import io.heckel.ntfy.db.Attachment
import io.heckel.ntfy.db.Icon import io.heckel.ntfy.db.Icon
import io.heckel.ntfy.db.Notification import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.util.deriveNotificationId
import io.heckel.ntfy.util.joinTags import io.heckel.ntfy.util.joinTags
import io.heckel.ntfy.util.toPriority import io.heckel.ntfy.util.toPriority
import java.lang.reflect.Type import java.lang.reflect.Type
@ -13,14 +14,17 @@ import java.lang.reflect.Type
class NotificationParser { class NotificationParser {
private val gson = Gson() private val gson = Gson()
fun parse(s: String, subscriptionId: Long = 0, notificationId: Int = 0): Notification? { fun parse(s: String, subscriptionId: Long = 0): Notification? {
val notificationWithTopic = parseWithTopic(s, subscriptionId = subscriptionId, notificationId = notificationId) val notificationWithTopic = parseWithTopic(s, subscriptionId = subscriptionId)
return notificationWithTopic?.notification 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) 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 return null
} }
val attachment = if (message.attachment?.url != null) { val attachment = if (message.attachment?.url != null) {
@ -32,31 +36,31 @@ class NotificationParser {
url = message.attachment.url, url = message.attachment.url,
) )
} else null } else null
val actions = if (message.actions != null) { val actions = message.actions?.map { a ->
message.actions.map { a -> Action(
Action( id = a.id,
id = a.id, action = a.action,
action = a.action, label = a.label,
label = a.label, clear = a.clear,
clear = a.clear, url = a.url,
url = a.url, method = a.method,
method = a.method, headers = a.headers,
headers = a.headers, body = a.body,
body = a.body, intent = a.intent,
intent = a.intent, extras = a.extras,
extras = a.extras, progress = null,
progress = null, error = null
error = null )
) }
}
} else null
val icon: Icon? = if (message.icon != null && message.icon != "") Icon(url = message.icon) else null val icon: Icon? = if (message.icon != null && message.icon != "") Icon(url = message.icon) else null
val sequenceId = message.sequenceId ?: message.id // Default to id if sequenceId not provided
val notification = Notification( val notification = Notification(
id = message.id, id = message.id,
subscriptionId = subscriptionId, subscriptionId = subscriptionId,
timestamp = message.time, timestamp = message.time,
sequenceId = sequenceId,
title = message.title ?: "", title = message.title ?: "",
message = message.message, message = message.message ?: "",
contentType = message.contentType ?: "", contentType = message.contentType ?: "",
encoding = message.encoding ?: "", encoding = message.encoding ?: "",
priority = toPriority(message.priority), priority = toPriority(message.priority),
@ -65,8 +69,9 @@ class NotificationParser {
icon = icon, icon = icon,
actions = actions, actions = actions,
attachment = attachment, attachment = attachment,
notificationId = notificationId, notificationId = deriveNotificationId(sequenceId),
deleted = false deleted = false,
event = message.event
) )
return NotificationWithTopic(message.topic, notification) return NotificationWithTopic(message.topic, notification)
} }

View file

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

View file

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

View file

@ -1,6 +1,12 @@
package io.heckel.ntfy.service 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.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@ -319,15 +325,33 @@ class SubscriberService : Service() {
val url = topicUrl(subscription.baseUrl, subscription.topic) val url = topicUrl(subscription.baseUrl, subscription.topic)
Log.d(TAG, "[$url] Received notification: $notification") Log.d(TAG, "[$url] Received notification: $notification")
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
if (repository.addNotification(notification)) { // This logic is (partially) duplicated in
Log.d(TAG, "[$url] Dispatching notification $notification") // - Android: SubscriberService::onNotificationReceived()
dispatcher.dispatch(subscription, notification) // - Android: FirebaseService::onMessageReceived()
} // - Web app: hooks.js:handleNotification()
wakeLock?.let { // - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ...
if (it.isHeld) {
it.release() 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() } }
} }
} }

View file

@ -21,7 +21,6 @@ import java.net.ProtocolException
import java.util.Calendar import java.util.Calendar
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference 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 * 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) { override fun onMessage(webSocket: WebSocket, text: String) {
synchronize("onMessage") { synchronize("onMessage") {
Log.d(TAG, "$shortUrl (gid=$globalId, lid=$id): Received message: $text") 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) { if (notificationWithTopic == null) {
Log.d(TAG, "$shortUrl (gid=$globalId, lid=$id): Irrelevant or unknown message. Discarding.") Log.d(TAG, "$shortUrl (gid=$globalId, lid=$id): Irrelevant or unknown message. Discarding.")
return@synchronize return@synchronize

View file

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

View file

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

View file

@ -511,3 +511,13 @@ fun Button.dangerButton() {
fun Long.nullIfZero(): Long? { fun Long.nullIfZero(): Long? {
return if (this == 0L) return null else this return if (this == 0L) return null else this
} }
/**
* Derives a stable notification ID from a string (typically the sequenceId or id).
* This allows Android to update existing notifications when a new version arrives.
* The result is always positive and never zero (0 means "no notification").
*/
fun deriveNotificationId(sequenceId: String): Int {
val hash = sequenceId.hashCode()
return if (hash == 0 || hash == Int.MIN_VALUE) 1 else abs(hash)
}

View file

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

View file

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

View file

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

View file

@ -1,7 +1,12 @@
package io.heckel.ntfy.firebase package io.heckel.ntfy.firebase
import android.content.Intent 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.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage import com.google.firebase.messaging.RemoteMessage
import io.heckel.ntfy.R 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.Attachment
import io.heckel.ntfy.db.Icon import io.heckel.ntfy.db.Icon
import io.heckel.ntfy.db.Notification import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.NotificationDispatcher import io.heckel.ntfy.msg.NotificationDispatcher
import io.heckel.ntfy.msg.NotificationParser import io.heckel.ntfy.msg.NotificationParser
import io.heckel.ntfy.msg.NotificationService
import io.heckel.ntfy.service.SubscriberService 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.nullIfZero
import io.heckel.ntfy.util.toPriority import io.heckel.ntfy.util.toPriority
import io.heckel.ntfy.util.topicShortUrl import io.heckel.ntfy.util.topicShortUrl
@ -21,7 +28,6 @@ import io.heckel.ntfy.work.PollWorker
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.random.Random
class FirebaseService : FirebaseMessagingService() { class FirebaseService : FirebaseMessagingService() {
private val repository by lazy { (application as Application).repository } private val repository by lazy { (application as Application).repository }
@ -41,9 +47,17 @@ class FirebaseService : FirebaseMessagingService() {
} }
// Dispatch event // 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 val data = remoteMessage.data
when (data["event"]) { when (data["event"]) {
ApiService.EVENT_MESSAGE -> handleMessage(remoteMessage) ApiService.EVENT_MESSAGE -> handleMessage(remoteMessage)
ApiService.EVENT_MESSAGE_DELETE -> handleMessageDelete(remoteMessage)
ApiService.EVENT_MESSAGE_CLEAR -> handleMessageClear(remoteMessage)
ApiService.EVENT_KEEPALIVE -> handleKeepalive(remoteMessage) ApiService.EVENT_KEEPALIVE -> handleKeepalive(remoteMessage)
ApiService.EVENT_POLL_REQUEST -> handlePollRequest(remoteMessage) ApiService.EVENT_POLL_REQUEST -> handlePollRequest(remoteMessage)
else -> Log.d(TAG, "Discarding unexpected message (2): from=${remoteMessage.from}, data=${data}") 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) 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) { private fun handleMessage(remoteMessage: RemoteMessage) {
val data = remoteMessage.data val data = remoteMessage.data
val id = data["id"] val id = data["id"]
@ -99,6 +153,7 @@ class FirebaseService : FirebaseMessagingService() {
val attachmentSize = data["attachment_size"]?.toLongOrNull()?.nullIfZero() val attachmentSize = data["attachment_size"]?.toLongOrNull()?.nullIfZero()
val attachmentExpires = data["attachment_expires"]?.toLongOrNull()?.nullIfZero() val attachmentExpires = data["attachment_expires"]?.toLongOrNull()?.nullIfZero()
val attachmentUrl = data["attachment_url"] val attachmentUrl = data["attachment_url"]
val sequenceId = data["sequence_id"]
val truncated = (data["truncated"] ?: "") == "1" val truncated = (data["truncated"] ?: "") == "1"
if (id == null || topic == null || message == null || timestamp == null) { 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}") 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 } else null
val icon: Icon? = if (iconUrl != null && iconUrl != "") Icon(url = iconUrl) else null val icon: Icon? = if (iconUrl != null && iconUrl != "") Icon(url = iconUrl) else null
val actualSequenceId = sequenceId ?: id
val notification = Notification( val notification = Notification(
id = id, id = id,
subscriptionId = subscription.id, subscriptionId = subscription.id,
timestamp = timestamp, timestamp = timestamp,
sequenceId = actualSequenceId,
title = title ?: "", title = title ?: "",
message = message, message = message,
contentType = contentType ?: "", contentType = contentType ?: "",
@ -141,10 +198,13 @@ class FirebaseService : FirebaseMessagingService() {
icon = icon, icon = icon,
actions = parser.parseActions(actions), actions = parser.parseActions(actions),
attachment = attachment, attachment = attachment,
notificationId = Random.nextInt(), notificationId = deriveNotificationId(actualSequenceId),
deleted = false 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}") Log.d(TAG, "Dispatching notification: from=${remoteMessage.from}, fcmprio=${remoteMessage.priority}, fcmprio_orig=${remoteMessage.originalPriority}, data=${data}")
dispatcher.dispatch(subscription, notification) dispatcher.dispatch(subscription, notification)
} }

View file

@ -1,9 +1,10 @@
Features: 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 * Connection error dialog to help diagnose connection issues
Maintenance + bug fixes: 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 in sharing dialog (thanks to @rogeliodh)
* Fix crash when exiting multi-delete in detail view * Fix crash when exiting multi-delete in detail view
* Fix potential crashes with icon downloader and backuper * Fix potential crashes with icon downloader and backuper