Sorta works

This commit is contained in:
Philipp Heckel 2026-01-05 21:37:32 -05:00
parent 5a5d6c9a2f
commit 03ed750091
11 changed files with 496 additions and 25 deletions

View file

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

View file

@ -184,6 +184,7 @@ class Backuper(val context: Context) {
id = n.id,
subscriptionId = n.subscriptionId,
timestamp = n.timestamp,
sid = n.sid ?: n.id,
title = n.title,
message = n.message,
contentType = n.contentType,
@ -315,6 +316,7 @@ class Backuper(val context: Context) {
id = n.id,
subscriptionId = n.subscriptionId,
timestamp = n.timestamp,
sid = n.sid,
title = n.title,
message = n.message,
contentType = n.contentType,
@ -391,6 +393,7 @@ data class Notification(
val id: String,
val subscriptionId: Long,
val timestamp: Long,
val sid: String?, // Sequence ID for updating notifications
val title: String,
val message: String,
val contentType: String, // "" or "text/markdown" (empty assumes "text/plain")

View file

@ -96,7 +96,8 @@ data class SubscriptionWithMetadata(
data class Notification(
@ColumnInfo(name = "id") val id: String,
@ColumnInfo(name = "subscriptionId") val subscriptionId: Long,
@ColumnInfo(name = "timestamp") val timestamp: Long, // Unix timestamp
@ColumnInfo(name = "timestamp") val timestamp: Long, // Unix timestamp in seconds
@ColumnInfo(name = "sid") val sid: String, // Sequence ID for updating notifications
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "message") val message: String,
@ColumnInfo(name = "contentType") val contentType: String, // "" or "text/markdown" (empty assume text/plain)
@ -109,7 +110,32 @@ data class Notification(
@ColumnInfo(name = "actions") val actions: List<Action>?,
@Embedded(prefix = "attachment_") val attachment: Attachment?,
@ColumnInfo(name = "deleted") val deleted: Boolean,
)
@Ignore val originalTime: Long = 0 // Original time of the notification sequence (computed, not stored)
) {
// Secondary constructor for Room (without ignored fields)
constructor(
id: String,
subscriptionId: Long,
timestamp: Long,
sid: String,
title: String,
message: String,
contentType: String,
encoding: String,
notificationId: Int,
priority: Int,
tags: String,
click: String,
icon: Icon?,
actions: List<Action>?,
attachment: Attachment?,
deleted: Boolean
) : this(
id, subscriptionId, timestamp, sid, title, message, contentType, encoding,
notificationId, priority, tags, click, icon, actions, attachment, deleted,
originalTime = 0
)
}
fun Notification.isMarkdown(): Boolean {
return contentType == "text/markdown"
@ -208,7 +234,7 @@ data class LogEntry(
this(0, timestamp, tag, level, message, exception)
}
@androidx.room.Database(entities = [Subscription::class, Notification::class, User::class, CustomHeader::class, LogEntry::class], version = 15)
@androidx.room.Database(entities = [Subscription::class, Notification::class, User::class, CustomHeader::class, LogEntry::class], version = 16)
@TypeConverters(Converters::class)
abstract class Database : RoomDatabase() {
abstract fun subscriptionDao(): SubscriptionDao
@ -239,6 +265,7 @@ abstract class Database : RoomDatabase() {
.addMigrations(MIGRATION_12_13)
.addMigrations(MIGRATION_13_14)
.addMigrations(MIGRATION_14_15)
.addMigrations(MIGRATION_15_16)
.fallbackToDestructiveMigration(true)
.build()
this.instance = instance
@ -356,6 +383,14 @@ abstract class Database : RoomDatabase() {
db.execSQL("CREATE TABLE CustomHeader (baseUrl TEXT NOT NULL, name TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY(baseUrl, name))")
}
}
private val MIGRATION_15_16 = object : Migration(15, 16) {
override fun migrate(db: SupportSQLiteDatabase) {
// Add sid column, defaulting to the id value
db.execSQL("ALTER TABLE Notification ADD COLUMN sid TEXT NOT NULL DEFAULT ''")
db.execSQL("UPDATE Notification SET sid = id WHERE sid = ''")
}
}
}
}
@ -474,6 +509,9 @@ interface NotificationDao {
@Query("UPDATE notification SET deleted = 1 WHERE id = :notificationId")
fun markAsDeleted(notificationId: String)
@Query("UPDATE notification SET deleted = 1 WHERE subscriptionId = :subscriptionId AND sid = :sid")
fun markAsDeletedBySid(subscriptionId: Long, sid: String)
@Query("UPDATE notification SET deleted = 1 WHERE subscriptionId = :subscriptionId")
fun markAllAsDeleted(subscriptionId: Long)

View file

@ -111,7 +111,37 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database)
}
fun getNotificationsLiveData(subscriptionId: Long): LiveData<List<Notification>> {
return notificationDao.listFlow(subscriptionId).asLiveData()
return notificationDao.listFlow(subscriptionId).asLiveData().map { notifications ->
groupNotificationsBySid(notifications)
}
}
/**
* Group notifications by sid (sequence ID) and return only the latest version of each.
* Also tracks the original time (earliest timestamp) for each sequence.
* Notifications are already sorted by timestamp DESC from the DAO query.
*/
private fun groupNotificationsBySid(notifications: List<Notification>): List<Notification> {
val latestBySid = mutableMapOf<String, Notification>()
val originalTimeBySid = mutableMapOf<String, Long>()
for (notification in notifications) {
// Track the latest notification for each sid (first one since sorted DESC)
if (!latestBySid.containsKey(notification.sid)) {
latestBySid[notification.sid] = notification
}
// Track the original (earliest) time for each sid
val currentOriginal = originalTimeBySid[notification.sid]
if (currentOriginal == null || notification.timestamp < currentOriginal) {
originalTimeBySid[notification.sid] = notification.timestamp
}
}
// Return latest notifications with originalTime set, sorted by timestamp descending
return latestBySid.values.map { notification ->
val originalTime = originalTimeBySid[notification.sid] ?: notification.timestamp
notification.copy(originalTime = originalTime)
}.sortedByDescending { it.timestamp }.toMutableList()
}
fun clearAllNotificationIds(subscriptionId: Long) {
@ -151,6 +181,10 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database)
notificationDao.markAsDeleted(notificationId)
}
fun markAsDeletedBySid(subscriptionId: Long, sid: String) {
notificationDao.markAsDeletedBySid(subscriptionId, sid)
}
fun markAllAsDeleted(subscriptionId: Long) {
notificationDao.markAllAsDeleted(subscriptionId)
}

View file

@ -9,6 +9,7 @@ import com.google.gson.annotations.SerializedName
data class Message(
val id: String,
val time: Long,
val sid: String?, // Sequence ID for updating notifications
val event: String,
val topic: String,
val priority: Int?,

View file

@ -32,29 +32,29 @@ class NotificationParser {
url = message.attachment.url,
)
} else null
val actions = if (message.actions != null) {
message.actions.map { a ->
Action(
id = a.id,
action = a.action,
label = a.label,
clear = a.clear,
url = a.url,
method = a.method,
headers = a.headers,
body = a.body,
intent = a.intent,
extras = a.extras,
progress = null,
error = null
)
}
} else null
val actions = message.actions?.map { a ->
Action(
id = a.id,
action = a.action,
label = a.label,
clear = a.clear,
url = a.url,
method = a.method,
headers = a.headers,
body = a.body,
intent = a.intent,
extras = a.extras,
progress = null,
error = null
)
}
val icon: Icon? = if (message.icon != null && message.icon != "") Icon(url = message.icon) else null
val sid = message.sid ?: message.id // Default to id if sid not provided
val notification = Notification(
id = message.id,
subscriptionId = subscriptionId,
timestamp = message.time,
sid = sid,
title = message.title ?: "",
message = message.message,
contentType = message.contentType ?: "",

View file

@ -332,11 +332,13 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, swipeDir: Int) {
val notification = adapter.get(viewHolder.absoluteAdapterPosition)
lifecycleScope.launch(Dispatchers.IO) {
repository.markAsDeleted(notification.id)
// Delete all notifications in the sequence (same sid)
repository.markAsDeletedBySid(notification.subscriptionId, notification.sid)
}
val snackbar = Snackbar.make(mainList, R.string.detail_item_snack_deleted, Snackbar.LENGTH_SHORT)
snackbar.setAction(R.string.detail_item_snack_undo) {
lifecycleScope.launch(Dispatchers.IO) {
// Note: undo only restores the latest notification, not the entire sequence
repository.undeleteNotification(notification.id)
}
}

View file

@ -114,7 +114,15 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope:
val unmatchedTags = unmatchedTags(splitTags(notification.tags))
val message = maybeAppendActionErrors(formatMessage(notification), notification)
dateView.text = formatDateShort(notification.timestamp)
val isModified = notification.originalTime != 0L && notification.originalTime != notification.timestamp
val dateText = if (isModified) {
val originalDate = formatDateShort(notification.originalTime)
val modifiedDate = formatDateShort(notification.timestamp)
context.getString(R.string.detail_item_date_modified, originalDate, modifiedDate)
} else {
formatDateShort(notification.timestamp)
}
dateView.text = dateText
if (notification.isMarkdown()) {
messageView.autoLinkMask = 0
markwon.setMarkdown(messageView, message.toString())

View file

@ -15,7 +15,14 @@ class DetailViewModel(private val repository: Repository) : ViewModel() {
}
fun markAsDeleted(notificationId: String) = viewModelScope.launch(Dispatchers.IO) {
repository.markAsDeleted(notificationId)
// Look up the notification to get its subscriptionId and sid, then delete the entire sequence
val notification = repository.getNotification(notificationId)
if (notification != null) {
repository.markAsDeletedBySid(notification.subscriptionId, notification.sid)
} else {
// Fallback to deleting by id if notification not found
repository.markAsDeleted(notificationId)
}
}
}

View file

@ -145,6 +145,7 @@
<string name="detail_instant_delivery_disabled">Instant delivery off</string>
<string name="detail_deep_link_subscribed_toast_message">Subscribed to topic %1$s</string>
<string name="detail_item_tags">Tags: %1$s</string>
<string name="detail_item_date_modified">%1$s (modified %2$s)</string>
<string name="detail_item_snack_deleted">Notification deleted</string>
<string name="detail_item_snack_undo">Undo</string>
<string name="detail_item_menu_open">Open file</string>

View file

@ -99,6 +99,7 @@ class FirebaseService : FirebaseMessagingService() {
val attachmentSize = data["attachment_size"]?.toLongOrNull()?.nullIfZero()
val attachmentExpires = data["attachment_expires"]?.toLongOrNull()?.nullIfZero()
val attachmentUrl = data["attachment_url"]
val sid = data["sid"]
val truncated = (data["truncated"] ?: "") == "1"
if (id == null || topic == null || message == null || timestamp == null) {
Log.d(TAG, "Discarding unexpected message: from=${remoteMessage.from}, fcmprio=${remoteMessage.priority}, fcmprio_orig=${remoteMessage.originalPriority}, data=${data}")
@ -131,6 +132,7 @@ class FirebaseService : FirebaseMessagingService() {
id = id,
subscriptionId = subscription.id,
timestamp = timestamp,
sid = sid ?: id,
title = title ?: "",
message = message,
contentType = contentType ?: "",