From 81b15e72f3ec6932bb83be09a1d6c0815509b9cc Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 13 May 2023 16:00:28 +0200 Subject: [PATCH] Only fetch and display a given notification once (#3626) When fetching: - Maintain a marker with the position of the newest fetched notification - Use the marker to determine which notifications to fetch - Fetch notifications with min_id to ensure that none are lost - Update the marker as necessary - Perform a one-time immediate fetch of notifications on startup When creating notifications: - Identify each notification with tag=${MastodonNotificationId}, id=${account.id} - Remove activeNotifications field, it's no longer necessary - Use the tag/id tuple to reliably identify existing notifications and avoid creating duplicates - Cancelling notifications for an account must iterate over all the notifications, and individually remove the notifications that exist for that account. - Limit notifications to a maximum of 40 (excluding summary notifications) - Remove notifications (oldest first) to get under this limit - Rate limit notification creation to 1 per second, so the OS won't drop them Adjust the summary notification: - Ensure the summary notification and the child notifications have the same group key - Dismiss the summary notification if there is only one child notification NotificationClearBroadcastReceiver is no longer needed, so remove it, and the need for deletePendingIntent. Fixes #3625, #3539 --- .../50.json | 995 ++++++++++++++++++ app/src/main/AndroidManifest.xml | 2 - .../notifications/NotificationFetcher.kt | 136 ++- .../notifications/NotificationHelper.java | 317 ++++-- .../notifications/PushNotificationHelper.kt | 3 +- .../keylesspalace/tusky/db/AccountEntity.kt | 1 - .../keylesspalace/tusky/db/AppDatabase.java | 10 +- .../tusky/di/BroadcastReceiverModule.kt | 4 - .../tusky/network/MastodonApi.kt | 10 +- .../NotificationClearBroadcastReceiver.kt | 42 - .../keylesspalace/tusky/MainActivityTest.kt | 4 +- 11 files changed, 1327 insertions(+), 197 deletions(-) create mode 100644 app/schemas/com.keylesspalace.tusky.db.AppDatabase/50.json delete mode 100644 app/src/main/java/com/keylesspalace/tusky/receiver/NotificationClearBroadcastReceiver.kt diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/50.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/50.json new file mode 100644 index 00000000..6b1a5461 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/50.json @@ -0,0 +1,995 @@ +{ + "formatVersion": 1, + "database": { + "version": 50, + "identityHash": "4eaf69e915d4a15f021547b725101acd", + "entities": [ + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL, `failedToSendNew` INTEGER NOT NULL, `scheduledAt` TEXT, `language` TEXT, `statusId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "failedToSend", + "columnName": "failedToSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "failedToSendNew", + "columnName": "failedToSendNew", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `clientId` TEXT, `clientSecret` TEXT, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationsReports` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL, `lastVisibleHomeTimelineStatusId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientSecret", + "columnName": "clientSecret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReports", + "columnName": "notificationsReports", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostLanguage", + "columnName": "defaultPostLanguage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastVisibleHomeTimelineStatusId", + "columnName": "lastVisibleHomeTimelineStatusId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videoSizeLimit", + "columnName": "videoSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageSizeLimit", + "columnName": "imageSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageMatrixLimit", + "columnName": "imageMatrixLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMediaAttachments", + "columnName": "maxMediaAttachments", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFields", + "columnName": "maxFields", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldNameLength", + "columnName": "maxFieldNameLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldValueLength", + "columnName": "maxFieldValueLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_editedAt` INTEGER, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, `s_language` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.editedAt", + "columnName": "s_editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.repliesCount", + "columnName": "s_repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.language", + "columnName": "s_language", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "accountId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "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, '4eaf69e915d4a15f021547b725101acd')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 55e3404a..232bf491 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -155,8 +155,6 @@ - Background worker + */ +@WorkerThread class NotificationFetcher @Inject constructor( private val mastodonApi: MastodonApi, private val accountManager: AccountManager, @@ -19,46 +31,111 @@ class NotificationFetcher @Inject constructor( for (account in accountManager.getAllAccountsOrderedByActive()) { if (account.notificationsEnabled) { try { - val notifications = fetchNotifications(account) - notifications.forEachIndexed { index, notification -> - NotificationHelper.make(context, notification, account, index == 0) + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + // Create sorted list of new notifications + val notifications = fetchNewNotifications(account) + .filter { filterNotification(notificationManager, account, it) } + .sortedWith(compareBy({ it.id.length }, { it.id })) // oldest notifications first + .toMutableList() + + // There's a maximum limit on the number of notifications an Android app + // can display. If the total number of notifications (current notifications, + // plus new ones) exceeds this then some newer notifications will be dropped. + // + // Err on the side of removing *older* notifications to make room for newer + // notifications. + val currentAndroidNotifications = notificationManager.activeNotifications + .sortedWith(compareBy({ it.tag.length }, { it.tag })) // oldest notifications first + + // Check to see if any notifications need to be removed + val toRemove = currentAndroidNotifications.size + notifications.size - MAX_NOTIFICATIONS + if (toRemove > 0) { + // Prefer to cancel old notifications first + currentAndroidNotifications.subList(0, min(toRemove, currentAndroidNotifications.size)) + .forEach { notificationManager.cancel(it.tag, it.id) } + + // Still got notifications to remove? Trim the list of new notifications, + // starting with the oldest. + while (notifications.size > MAX_NOTIFICATIONS) { + notifications.removeAt(0) + } } + + // Make and send the new notifications + // TODO: Use the batch notification API available in NotificationManagerCompat + // 1.11 and up (https://developer.android.com/jetpack/androidx/releases/core#1.11.0-alpha01) + // when it is released. + notifications.forEachIndexed { index, notification -> + val androidNotification = NotificationHelper.make( + context, + notificationManager, + notification, + account, + index == 0 + ) + notificationManager.notify(notification.id, account.id.toInt(), androidNotification) + // Android will rate limit / drop notifications if they're posted too + // quickly. There is no indication to the user that this happened. + // See https://github.com/tuskyapp/Tusky/pull/3626#discussion_r1192963664 + Thread.sleep(1000) + } + + NotificationHelper.updateSummaryNotifications( + context, + notificationManager, + account + ) + accountManager.saveAccount(account) } catch (e: Exception) { - Log.w(TAG, "Error while fetching notifications", e) + Log.e(TAG, "Error while fetching notifications", e) } } } } - private fun fetchNotifications(account: AccountEntity): MutableList { + /** + * Fetch new Mastodon Notifications and update the marker position. + * + * Here, "new" means "notifications with IDs newer than notifications the user has already + * seen." + * + * The "water mark" for Mastodon Notification IDs are stored in two places. + * + * - acccount.lastNotificationId -- the ID of the top-most notification when the user last + * left the Notifications tab. + * - The Mastodon "marker" API -- the ID of the most recent notification fetched here. + * + * The user may have refreshed the "Notifications" tab and seen notifications newer than the + * ones that were last fetched here. So `lastNotificationId` takes precedence if it is greater + * than the marker. + */ + private fun fetchNewNotifications(account: AccountEntity): List { val authHeader = String.format("Bearer %s", account.accessToken) - // We fetch marker to not load/show notifications which user has already seen - val marker = fetchMarker(authHeader, account) - if (marker != null && account.lastNotificationId.isLessThan(marker.lastReadId)) { - account.lastNotificationId = marker.lastReadId + + val minId = when (val marker = fetchMarker(authHeader, account)) { + null -> account.lastNotificationId.takeIf { it != "0" } + else -> if (marker.lastReadId > account.lastNotificationId) marker.lastReadId else account.lastNotificationId } - Log.d(TAG, "getting Notifications for " + account.fullName) + + Log.d(TAG, "getting Notifications for ${account.fullName}, min_id: $minId") + val notifications = mastodonApi.notificationsWithAuth( authHeader, account.domain, - account.lastNotificationId + minId ).blockingGet() - val newId = account.lastNotificationId - var newestId = "" - val result = mutableListOf() - for (notification in notifications.reversed()) { - val currentId = notification.id - if (newestId.isLessThan(currentId)) { - newestId = currentId - account.lastNotificationId = currentId - } - if (newId.isLessThan(currentId)) { - result.add(notification) - } + // Notifications are returned in order, most recent first. Save the newest notification ID + // in the marker. + notifications.firstOrNull()?.let { + val newMarkerId = notifications.first().id + Log.d(TAG, "updating notification marker to: $newMarkerId") + mastodonApi.updateMarkersWithAuth(authHeader, notificationsLastReadId = newMarkerId) } - return result + + return notifications } private fun fetchMarker(authHeader: String, account: AccountEntity): Marker? { @@ -78,6 +155,13 @@ class NotificationFetcher @Inject constructor( } companion object { - const val TAG = "NotificationFetcher" + private const val TAG = "NotificationFetcher" + + // There's a system limit on the maximum number of notifications an app + // can show, NotificationManagerService.MAX_PACKAGE_NOTIFICATIONS. Unfortunately + // that's not available to client code or via the NotificationManager API. + // The current value in the Android source code is 50, set 40 here to both + // be conservative, and allow some headroom for summary notifications. + private const val MAX_NOTIFICATIONS = 40 } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java index b855049d..638ab8e1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java @@ -16,6 +16,7 @@ package com.keylesspalace.tusky.components.notifications; +import static com.keylesspalace.tusky.BuildConfig.APPLICATION_ID; import static com.keylesspalace.tusky.util.StatusParsingHelper.parseAsMastodonHtml; import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription; @@ -28,18 +29,21 @@ import android.content.Intent; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.os.Build; +import android.os.Bundle; import android.provider.Settings; +import android.service.notification.StatusBarNotification; import android.text.TextUtils; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; import androidx.core.app.RemoteInput; import androidx.core.app.TaskStackBuilder; import androidx.work.Constraints; import androidx.work.NetworkType; +import androidx.work.OneTimeWorkRequest; +import androidx.work.OutOfQuotaPolicy; import androidx.work.PeriodicWorkRequest; import androidx.work.WorkManager; import androidx.work.WorkRequest; @@ -56,25 +60,20 @@ import com.keylesspalace.tusky.entity.Notification; import com.keylesspalace.tusky.entity.Poll; import com.keylesspalace.tusky.entity.PollOption; import com.keylesspalace.tusky.entity.Status; -import com.keylesspalace.tusky.receiver.NotificationClearBroadcastReceiver; import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver; import com.keylesspalace.tusky.util.StringUtils; import com.keylesspalace.tusky.viewdata.PollViewDataKt; -import org.json.JSONArray; -import org.json.JSONException; - import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.schedulers.Schedulers; - public class NotificationHelper { private static int notificationId = 0; @@ -84,7 +83,7 @@ public class NotificationHelper { */ public static final String ACCOUNT_ID = "account_id"; - public static final String TYPE = "type"; + public static final String TYPE = APPLICATION_ID + ".notification.type"; private static final String TAG = "NotificationHelper"; @@ -127,51 +126,53 @@ public class NotificationHelper { */ private static final String NOTIFICATION_PULL_TAG = "pullNotifications"; + /** Tag for the summary notification */ + private static final String GROUP_SUMMARY_TAG = APPLICATION_ID + ".notification.group_summary"; + + /** The name of the account that caused the notification, for use in a summary */ + private static final String EXTRA_ACCOUNT_NAME = APPLICATION_ID + ".notification.extra.account_name"; + + /** The notification's type (string representation of a Notification.Type) */ + private static final String EXTRA_NOTIFICATION_TYPE = APPLICATION_ID + ".notification.extra.notification_type"; + /** - * Takes a given Mastodon notification and either creates a new Android notification or updates - * the state of the existing notification to reflect the new interaction. + * Takes a given Mastodon notification and creates a new Android notification or updates the + * existing Android notification. + *

+ * The Android notification has it's tag set to the Mastodon notification ID, and it's ID set + * to the ID of the account that received the notification. * * @param context to access application preferences and services * @param body a new Mastodon notification * @param account the account for which the notification should be shown + * @return the new notification */ - - public static void make(final Context context, Notification body, AccountEntity account, boolean isFirstOfBatch) { + @NonNull + public static android.app.Notification make(final Context context, NotificationManager notificationManager, Notification body, AccountEntity account, boolean isFirstOfBatch) { body = body.rewriteToStatusTypeIfNeeded(account.getAccountId()); + String mastodonNotificationId = body.getId(); + int accountId = (int) account.getId(); - if (!filterNotification(account, body, context)) { - return; - } - - String rawCurrentNotifications = account.getActiveNotifications(); - JSONArray currentNotifications; - - try { - currentNotifications = new JSONArray(rawCurrentNotifications); - } catch (JSONException e) { - currentNotifications = new JSONArray(); - } - - for (int i = 0; i < currentNotifications.length(); i++) { - try { - if (currentNotifications.getString(i).equals(body.getAccount().getName())) { - currentNotifications.remove(i); - break; - } - } catch (JSONException e) { - Log.d(TAG, Log.getStackTraceString(e)); + // Check for an existing notification with this Mastodon Notification ID + android.app.Notification existingAndroidNotification = null; + StatusBarNotification[] activeNotifications = notificationManager.getActiveNotifications(); + for (StatusBarNotification androidNotification : activeNotifications) { + if (mastodonNotificationId.equals(androidNotification.getTag()) && accountId == androidNotification.getId()) { + existingAndroidNotification = androidNotification.getNotification(); } } - currentNotifications.put(body.getAccount().getName()); - - account.setActiveNotifications(currentNotifications.toString()); - // Notification group member // ========================= - final NotificationCompat.Builder builder = newNotification(context, body, account, false); notificationId++; + // Create the notification -- either create a new one, or use the existing one. + NotificationCompat.Builder builder; + if (existingAndroidNotification == null) { + builder = newAndroidNotification(context, body, account); + } else { + builder = new NotificationCompat.Builder(context, existingAndroidNotification); + } builder.setContentTitle(titleForType(context, body, account)) .setContentText(bodyForType(body, context, account.getAlwaysOpenSpoiler())); @@ -233,51 +234,136 @@ public class NotificationHelper { builder.setCategory(NotificationCompat.CATEGORY_SOCIAL); builder.setOnlyAlertOnce(true); - // only alert for the first notification of a batch to avoid multiple alerts at once + Bundle extras = new Bundle(); + // Add the sending account's name, so it can be used when summarising this notification + extras.putString(EXTRA_ACCOUNT_NAME, body.getAccount().getName()); + extras.putString(EXTRA_NOTIFICATION_TYPE, body.getType().toString()); + builder.addExtras(extras); + + // Only alert for the first notification of a batch to avoid multiple alerts at once if(!isFirstOfBatch) { builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY); } - // Summary - final NotificationCompat.Builder summaryBuilder = newNotification(context, body, account, true); + return builder.build(); + } - if (currentNotifications.length() != 1) { - try { - String title = context.getResources().getQuantityString(R.plurals.notification_title_summary, currentNotifications.length(), currentNotifications.length()); - String text = joinNames(context, currentNotifications); - summaryBuilder.setContentTitle(title) - .setContentText(text); - } catch (JSONException e) { - Log.d(TAG, Log.getStackTraceString(e)); - } + /** + * Updates the summary notifications for each notification group. + *

+ * Notifications are sent to channels. Within each channel they may be grouped, and the group + * may have a summary. + *

+ * Tusky uses N notification channels for each account, each channel corresponds to a type + * of notification (follow, reblog, mention, etc). Therefore each channel also has exactly + * 0 or 1 summary notifications along with its regular notifications. + *

+ * The group key is the same as the channel ID. + *

+ * Regnerates the summary notifications for all active Tusky notifications for `account`. + * This may delete the summary notification if there are no active notifications for that + * account in a group. + * + * @see Create a + * notification group + * @param context to access application preferences and services + * @param notificationManager the system's NotificationManager + * @param account the account for which the notification should be shown + */ + public static void updateSummaryNotifications(Context context, NotificationManager notificationManager, AccountEntity account) { + // Map from the channel ID to a list of notifications in that channel. Those are the + // notifications that will be summarised. + Map> channelGroups = new HashMap<>(); + int accountId = (int) account.getId(); + + // Initialise the map with all channel IDs. + for (Notification.Type ty : Notification.Type.values()) { + channelGroups.put(getChannelId(account, ty), new ArrayList<>()); } - summaryBuilder.setSubText(account.getFullName()); - summaryBuilder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE); - summaryBuilder.setCategory(NotificationCompat.CATEGORY_SOCIAL); - summaryBuilder.setOnlyAlertOnce(true); - summaryBuilder.setGroupSummary(true); + // Fetch all existing notifications. Add them to the map, ignoring notifications that: + // - belong to a different account + // - are summary notifications + for (StatusBarNotification sn : notificationManager.getActiveNotifications()) { + if (sn.getId() != accountId) continue; - NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + String channelId = sn.getNotification().getGroup(); + String summaryTag = GROUP_SUMMARY_TAG + "." + channelId; + if (summaryTag.equals(sn.getTag())) continue; - notificationManager.notify(notificationId, builder.build()); - if (currentNotifications.length() == 1) { - notificationManager.notify((int) account.getId(), builder.setGroupSummary(true).build()); - } else { - notificationManager.notify((int) account.getId(), summaryBuilder.build()); + // TODO: API 26 supports getting the channel ID directly (sn.getNotification().getChannelId()). + // This works here because the channelId and the groupKey are the same. + List members = channelGroups.get(channelId); + if (members == null) { // can't happen, but just in case... + Log.e(TAG, "members == null for channel ID " + channelId); + continue; + } + members.add(sn); + } + + // Create, update, or cancel the summary notifications for each group. + for (Map.Entry> channelGroup : channelGroups.entrySet()) { + String channelId = channelGroup.getKey(); + List members = channelGroup.getValue(); + String summaryTag = GROUP_SUMMARY_TAG + "." + channelId; + + // If there are 0-1 notifications in this group then the additional summary + // notification is not needed and can be cancelled. + if (members.size() <= 1) { + notificationManager.cancel(summaryTag, accountId); + continue; + } + + // Create a notification that summarises the other notifications in this group + + // All notifications in this group have the same type, so get it from the first. + String notificationType = members.get(0).getNotification().extras.getString(EXTRA_NOTIFICATION_TYPE); + + Intent summaryResultIntent = new Intent(context, MainActivity.class); + summaryResultIntent.putExtra(ACCOUNT_ID, (long) accountId); + summaryResultIntent.putExtra(TYPE, notificationType); + TaskStackBuilder summaryStackBuilder = TaskStackBuilder.create(context); + summaryStackBuilder.addParentStack(MainActivity.class); + summaryStackBuilder.addNextIntent(summaryResultIntent); + + PendingIntent summaryResultPendingIntent = summaryStackBuilder.getPendingIntent((int) (notificationId + account.getId() * 10000), + pendingIntentFlags(false)); + + String title = context.getResources().getQuantityString(R.plurals.notification_title_summary, members.size(), members.size()); + String text = joinNames(context, members); + + NotificationCompat.Builder summaryBuilder = new NotificationCompat.Builder(context, channelId) + .setSmallIcon(R.drawable.ic_notify) + .setContentIntent(summaryResultPendingIntent) + .setColor(context.getColor(R.color.notification_color)) + .setAutoCancel(true) + .setShortcutId(Long.toString(account.getId())) + .setDefaults(0) // So it doesn't ring twice, notify only in Target callback + .setContentTitle(title) + .setContentText(text) + .setSubText(account.getFullName()) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setCategory(NotificationCompat.CATEGORY_SOCIAL) + .setOnlyAlertOnce(true) + .setGroup(channelId) + .setGroupSummary(true); + + setSoundVibrationLight(account, summaryBuilder); + + // TODO: Use the batch notification API available in NotificationManagerCompat + // 1.11 and up (https://developer.android.com/jetpack/androidx/releases/core#1.11.0-alpha01) + // when it is released. + notificationManager.notify(summaryTag, accountId, summaryBuilder.build()); + + // Android will rate limit / drop notifications if they're posted too + // quickly. There is no indication to the user that this happened. + // See https://github.com/tuskyapp/Tusky/pull/3626#discussion_r1192963664 + try { Thread.sleep(1000); } catch (InterruptedException ignored) { } } } - private static NotificationCompat.Builder newNotification(Context context, Notification body, AccountEntity account, boolean summary) { - Intent summaryResultIntent = new Intent(context, MainActivity.class); - summaryResultIntent.putExtra(ACCOUNT_ID, account.getId()); - summaryResultIntent.putExtra(TYPE, body.getType().name()); - TaskStackBuilder summaryStackBuilder = TaskStackBuilder.create(context); - summaryStackBuilder.addParentStack(MainActivity.class); - summaryStackBuilder.addNextIntent(summaryResultIntent); - PendingIntent summaryResultPendingIntent = summaryStackBuilder.getPendingIntent((int) (notificationId + account.getId() * 10000), - pendingIntentFlags(false)); + private static NotificationCompat.Builder newAndroidNotification(Context context, Notification body, AccountEntity account) { // we have to switch account here Intent eventResultIntent = new Intent(context, MainActivity.class); @@ -290,22 +376,19 @@ public class NotificationHelper { PendingIntent eventResultPendingIntent = eventStackBuilder.getPendingIntent((int) account.getId(), pendingIntentFlags(false)); - Intent deleteIntent = new Intent(context, NotificationClearBroadcastReceiver.class); - deleteIntent.putExtra(ACCOUNT_ID, account.getId()); - PendingIntent deletePendingIntent = PendingIntent.getBroadcast(context, summary ? (int) account.getId() : notificationId, deleteIntent, - pendingIntentFlags(false)); + String channelId = getChannelId(account, body); + assert channelId != null; - NotificationCompat.Builder builder = new NotificationCompat.Builder(context, getChannelId(account, body)) + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId) .setSmallIcon(R.drawable.ic_notify) - .setContentIntent(summary ? summaryResultPendingIntent : eventResultPendingIntent) - .setDeleteIntent(deletePendingIntent) + .setContentIntent(eventResultPendingIntent) .setColor(context.getColor(R.color.notification_color)) - .setGroup(account.getAccountId()) + .setGroup(channelId) .setAutoCancel(true) .setShortcutId(Long.toString(account.getId())) .setDefaults(0); // So it doesn't ring twice, notify only in Target callback - setupPreferences(account, builder); + setSoundVibrationLight(account, builder); return builder; } @@ -453,7 +536,6 @@ public class NotificationHelper { } notificationManager.createNotificationChannels(channels); - } } @@ -495,6 +577,15 @@ public class NotificationHelper { WorkManager workManager = WorkManager.getInstance(context); workManager.cancelAllWorkByTag(NOTIFICATION_PULL_TAG); + // Periodic work requests are supposed to start running soon after being enqueued. In + // practice that may not be soon enough, so create and enqueue an expedited one-time + // request to get new notifications immediately. + WorkRequest fetchNotifications = new OneTimeWorkRequest.Builder(NotificationWorker.class) + .setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST) + .setConstraints(new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .build(); + workManager.enqueue(fetchNotifications); + WorkRequest workRequest = new PeriodicWorkRequest.Builder( NotificationWorker.class, PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS, TimeUnit.MILLISECONDS, @@ -502,6 +593,7 @@ public class NotificationHelper { ) .addTag(NOTIFICATION_PULL_TAG) .setConstraints(new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .setInitialDelay(5, TimeUnit.MINUTES) .build(); workManager.enqueue(workRequest); @@ -516,31 +608,23 @@ public class NotificationHelper { public static void clearNotificationsForActiveAccount(@NonNull Context context, @NonNull AccountManager accountManager) { AccountEntity account = accountManager.getActiveAccount(); - if (account != null && !account.getActiveNotifications().equals("[]")) { - Single.fromCallable(() -> { - account.setActiveNotifications("[]"); - accountManager.saveAccount(account); + if (account == null) return; + int accountId = (int) account.getId(); - NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - notificationManager.cancel((int) account.getId()); - return true; - }) - .subscribeOn(Schedulers.io()) - .subscribe(); + NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + for (StatusBarNotification androidNotification : notificationManager.getActiveNotifications()) { + if (accountId == androidNotification.getId()) { + notificationManager.cancel(androidNotification.getTag(), androidNotification.getId()); + } } } - public static boolean filterNotification(AccountEntity account, Notification notification, - Context context) { - return filterNotification(account, notification.getType(), context); + public static boolean filterNotification(NotificationManager notificationManager, AccountEntity account, @NonNull Notification notification) { + return filterNotification(notificationManager, account, notification.getType()); } - public static boolean filterNotification(AccountEntity account, Notification.Type type, - Context context) { - + public static boolean filterNotification(@NonNull NotificationManager notificationManager, @NonNull AccountEntity account, @NonNull Notification.Type type) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - String channelId = getChannelId(account, type); if(channelId == null) { // unknown notificationtype @@ -610,9 +694,7 @@ public class NotificationHelper { } - private static void setupPreferences(AccountEntity account, - NotificationCompat.Builder builder) { - + private static void setSoundVibrationLight(AccountEntity account, NotificationCompat.Builder builder) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { return; //do nothing on Android O or newer, the system uses the channel settings anyway } @@ -630,28 +712,29 @@ public class NotificationHelper { } } - private static String wrapItemAt(JSONArray array, int index) throws JSONException { - return StringUtils.unicodeWrap(array.get(index).toString()); + private static String wrapItemAt(StatusBarNotification notification) { + return StringUtils.unicodeWrap(notification.getNotification().extras.getString(EXTRA_ACCOUNT_NAME));//getAccount().getName()); } @Nullable - private static String joinNames(Context context, JSONArray array) throws JSONException { - if (array.length() > 3) { - int length = array.length(); + private static String joinNames(Context context, List notifications) { + if (notifications.size() > 3) { + int length = notifications.size(); + //notifications.get(0).getNotification().extras.getString(EXTRA_ACCOUNT_NAME); return String.format(context.getString(R.string.notification_summary_large), - wrapItemAt(array, length - 1), - wrapItemAt(array, length - 2), - wrapItemAt(array, length - 3), + wrapItemAt(notifications.get(length - 1)), + wrapItemAt(notifications.get(length - 2)), + wrapItemAt(notifications.get(length - 3)), length - 3); - } else if (array.length() == 3) { + } else if (notifications.size() == 3) { return String.format(context.getString(R.string.notification_summary_medium), - wrapItemAt(array, 2), - wrapItemAt(array, 1), - wrapItemAt(array, 0)); - } else if (array.length() == 2) { + wrapItemAt(notifications.get(2)), + wrapItemAt(notifications.get(1)), + wrapItemAt(notifications.get(0))); + } else if (notifications.size() == 2) { return String.format(context.getString(R.string.notification_summary_small), - wrapItemAt(array, 1), - wrapItemAt(array, 0)); + wrapItemAt(notifications.get(1)), + wrapItemAt(notifications.get(0))); } return null; diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt index c06d659d..f61307e3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt @@ -151,8 +151,9 @@ fun disableAllNotifications(context: Context, accountManager: AccountManager) { private fun buildSubscriptionData(context: Context, account: AccountEntity): Map = buildMap { + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager Notification.Type.visibleTypes.forEach { - put("data[alerts][${it.presentation}]", NotificationHelper.filterNotification(account, it, context)) + put("data[alerts][${it.presentation}]", NotificationHelper.filterNotification(notificationManager, account, it)) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt index 1fcfb9de..62e5f538 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt @@ -71,7 +71,6 @@ data class AccountEntity( */ var mediaPreviewEnabled: Boolean = true, var lastNotificationId: String = "0", - var activeNotifications: String = "[]", var emojis: List = emptyList(), var tabPreferences: List = defaultTabs(), var notificationsFilter: String = "[\"follow_request\"]", diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java index fb9ecd60..b9a0151e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -18,7 +18,9 @@ package com.keylesspalace.tusky.db; import androidx.annotation.NonNull; import androidx.room.AutoMigration; import androidx.room.Database; +import androidx.room.DeleteColumn; import androidx.room.RoomDatabase; +import androidx.room.migration.AutoMigrationSpec; import androidx.room.migration.Migration; import androidx.sqlite.db.SupportSQLiteDatabase; @@ -39,9 +41,10 @@ import java.io.File; TimelineAccountEntity.class, ConversationEntity.class }, - version = 49, + version = 50, autoMigrations = { - @AutoMigration(from = 48, to = 49) + @AutoMigration(from = 48, to = 49), + @AutoMigration(from = 49, to = 50, spec = AppDatabase.MIGRATION_49_50.class) } ) public abstract class AppDatabase extends RoomDatabase { @@ -665,4 +668,7 @@ public abstract class AppDatabase extends RoomDatabase { database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `filtered` TEXT"); } }; + + @DeleteColumn(tableName = "AccountEntity", columnName = "activeNotifications") + static class MIGRATION_49_50 implements AutoMigrationSpec { } } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/BroadcastReceiverModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/BroadcastReceiverModule.kt index e071fc84..82c83e1d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/BroadcastReceiverModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/BroadcastReceiverModule.kt @@ -17,7 +17,6 @@ package com.keylesspalace.tusky.di import com.keylesspalace.tusky.receiver.NotificationBlockStateBroadcastReceiver -import com.keylesspalace.tusky.receiver.NotificationClearBroadcastReceiver import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver import com.keylesspalace.tusky.receiver.UnifiedPushBroadcastReceiver import dagger.Module @@ -28,9 +27,6 @@ abstract class BroadcastReceiverModule { @ContributesAndroidInjector abstract fun contributeSendStatusBroadcastReceiver(): SendStatusBroadcastReceiver - @ContributesAndroidInjector - abstract fun contributeNotificationClearBroadcastReceiver(): NotificationClearBroadcastReceiver - @ContributesAndroidInjector abstract fun contributeUnifiedPushBroadcastReceiver(): UnifiedPushBroadcastReceiver diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index 7b8f1931..0346ee04 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -152,11 +152,19 @@ interface MastodonApi { @Query("timeline[]") timelines: List ): Single> + @FormUrlEncoded + @POST("api/v1/markers") + fun updateMarkersWithAuth( + @Header("Authorization") auth: String, + @Field("home[last_read_id]") homeLastReadId: String? = null, + @Field("notifications[last_read_id]") notificationsLastReadId: String? = null + ): NetworkResult + @GET("api/v1/notifications") fun notificationsWithAuth( @Header("Authorization") auth: String, @Header(DOMAIN_HEADER) domain: String, - @Query("since_id") sinceId: String? + @Query("min_id") minId: String? ): Single> @POST("api/v1/notifications/clear") diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationClearBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationClearBroadcastReceiver.kt deleted file mode 100644 index 6d4e9719..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationClearBroadcastReceiver.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* Copyright 2018 Conny Duck - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.receiver - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import com.keylesspalace.tusky.components.notifications.NotificationHelper -import com.keylesspalace.tusky.db.AccountManager -import dagger.android.AndroidInjection -import javax.inject.Inject - -class NotificationClearBroadcastReceiver : BroadcastReceiver() { - - @Inject - lateinit var accountManager: AccountManager - - override fun onReceive(context: Context, intent: Intent) { - AndroidInjection.inject(this, context) - - val accountId = intent.getLongExtra(NotificationHelper.ACCOUNT_ID, -1) - - val account = accountManager.getAccountById(accountId) - if (account != null) { - account.activeNotifications = "[]" - accountManager.saveAccount(account) - } - } -} diff --git a/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt index 6af56fd6..8ad20a41 100644 --- a/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt @@ -90,8 +90,9 @@ class MainActivityTest { NotificationHelper.createNotificationChannelsForAccount(accountEntity, context) runInBackground { - NotificationHelper.make( + val notification = NotificationHelper.make( context, + notificationManager, Notification( type = type, id = "id", @@ -110,6 +111,7 @@ class MainActivityTest { accountEntity, true ) + notificationManager.notify("id", 1, notification) } val notification = shadowNotificationManager.allNotifications.first()