Merge pull request #151 from binwiederhier/303-update-notifications
Update/delete/clear notifications
This commit is contained in:
commit
dd968574a7
22 changed files with 783 additions and 120 deletions
429
app/schemas/io.heckel.ntfy.db.Database/18.json
Normal file
429
app/schemas/io.heckel.ntfy.db.Database/18.json
Normal 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')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"/>
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 -> {
|
||||||
|
|
|
||||||
|
|
@ -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?,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
77
app/src/main/java/io/heckel/ntfy/msg/Poller.kt
Normal file
77
app/src/main/java/io/heckel/ntfy/msg/Poller.kt
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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() } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue