diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/49.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/49.json
new file mode 100644
index 0000000000..622da448a3
--- /dev/null
+++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/49.json
@@ -0,0 +1,1057 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 49,
+ "identityHash": "90c888ce17bbc144b95208b6ecc4e10a",
+ "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, `activeNotifications` 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)",
+ "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": "activeNotifications",
+ "columnName": "activeNotifications",
+ "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
+ }
+ ],
+ "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": []
+ },
+ {
+ "tableName": "OccurrenceEntity",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER, `type` TEXT NOT NULL, `what` TEXT NOT NULL, `startedAt` INTEGER NOT NULL, `finishedAt` INTEGER, `code` INTEGER, `callTrace` TEXT NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accountId",
+ "columnName": "accountId",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "what",
+ "columnName": "what",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "startedAt",
+ "columnName": "startedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "finishedAt",
+ "columnName": "finishedAt",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "code",
+ "columnName": "code",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "callTrace",
+ "columnName": "callTrace",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "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, 'e5748dfe3c822a1305530005ac41f534')"
+ ]
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0eaaf655f7..3fffe07293 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -153,6 +153,7 @@
+
diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt
index 0d8da3be97..556826c28b 100644
--- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt
@@ -73,6 +73,7 @@ import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.components.notifications.disableAllNotifications
import com.keylesspalace.tusky.components.notifications.enablePushNotificationsWithFallback
import com.keylesspalace.tusky.components.notifications.showMigrationNoticeIfNecessary
+import com.keylesspalace.tusky.components.occurrence.OccurrenceActivity
import com.keylesspalace.tusky.components.preference.PreferencesActivity
import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity
import com.keylesspalace.tusky.components.search.SearchActivity
@@ -183,6 +184,19 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
val activeAccount = accountManager.activeAccount
?: return // will be redirected to LoginActivity by BaseActivity
+ // TODO this works but seems a bit blunt for "intercept relevant log messages"?
+// lifecycleScope.launch {
+// Runtime.getRuntime().exec("logcat -c")
+// Runtime.getRuntime().exec("logcat")
+// .inputStream
+// .bufferedReader()
+// .useLines { lines -> lines.forEach {
+// val x = it
+// val y = 0
+// }
+// }
+// }
+
var showNotificationTab = false
if (intent != null) {
/** there are two possibilities the accountId can be passed to MainActivity:
@@ -632,20 +646,31 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
if (BuildConfig.DEBUG) {
- // Add a "Developer tools" entry. Code that makes it easier to
- // set the app state at runtime belongs here, it will never
- // be exposed to users.
- binding.mainDrawer.addItems(
- DividerDrawerItem(),
- secondaryDrawerItem {
- nameText = "Developer tools"
- isEnabled = true
- iconicsIcon = GoogleMaterial.Icon.gmd_developer_mode
- onClick = {
- buildDeveloperToolsDialog().show()
+ binding.mainDrawer.apply {
+ addItems(
+ DividerDrawerItem(),
+ secondaryDrawerItem {
+ nameRes = R.string.action_occurrences
+ isEnabled = true
+ iconicsIcon = GoogleMaterial.Icon.gmd_event_note
+ onClick = {
+ startActivityWithSlideInAnimation(Intent(context, OccurrenceActivity::class.java))
+ }
+ },
+
+ // Add a "Developer tools" entry. Code that makes it easier to
+ // set the app state at runtime belongs here, it will never
+ // be exposed to users.
+ secondaryDrawerItem {
+ nameText = "Developer tools"
+ isEnabled = true
+ iconicsIcon = GoogleMaterial.Icon.gmd_developer_mode
+ onClick = {
+ buildDeveloperToolsDialog().show()
+ }
}
- }
- )
+ )
+ }
}
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt
index ef5b8cab44..5354eb67ff 100644
--- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt
@@ -21,6 +21,7 @@ import android.util.Log
import androidx.work.WorkManager
import autodispose2.AutoDisposePlugins
import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory
+import com.keylesspalace.tusky.components.occurrence.OccurrenceRepository
import com.keylesspalace.tusky.di.AppInjector
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.settings.SCHEMA_VERSION
@@ -50,6 +51,9 @@ class TuskyApplication : Application(), HasAndroidInjector {
@Inject
lateinit var sharedPreferences: SharedPreferences
+ @Inject
+ lateinit var occurrenceRespository: OccurrenceRepository
+
override fun onCreate() {
// Uncomment me to get StrictMode violation logs
// if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
@@ -63,6 +67,13 @@ class TuskyApplication : Application(), HasAndroidInjector {
// }
super.onCreate()
+ val existingUncaughtHandler = Thread.getDefaultUncaughtExceptionHandler()
+ Thread.setDefaultUncaughtExceptionHandler { t, e ->
+ occurrenceRespository.handleException(e)
+
+ existingUncaughtHandler?.uncaughtException(t, e)
+ }
+
Security.insertProviderAt(Conscrypt.newProvider(), 1)
AutoDisposePlugins.setHideProxies(false) // a small performance optimization
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/occurrence/LogToDbInterceptor.kt b/app/src/main/java/com/keylesspalace/tusky/components/occurrence/LogToDbInterceptor.kt
new file mode 100644
index 0000000000..2ad20151dd
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/occurrence/LogToDbInterceptor.kt
@@ -0,0 +1,44 @@
+/* Copyright Tusky Contributors
+ *
+ * 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.components.occurrence
+
+import okhttp3.Interceptor
+import okhttp3.Request
+import okhttp3.Response
+import java.io.IOException
+
+class LogToDbInterceptor(private val occurrenceRespository: OccurrenceRepository) : Interceptor {
+ @Throws(IOException::class)
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val request: Request = chain.request()
+ val what = request.method + " " + request.url.toString()
+
+ val entityId = occurrenceRespository.handleApiCallStart(what)
+
+ val response: Response
+ try {
+ response = chain.proceed(request)
+ occurrenceRespository.handleApiCallFinish(entityId, response.code)
+ } catch (e: Exception) {
+ // TODO this case is used? If so add its message to the occurrence entity?
+ occurrenceRespository.handleApiCallFinish(entityId, 499)
+
+ throw e
+ }
+
+ return response
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/occurrence/OccurrenceActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/occurrence/OccurrenceActivity.kt
new file mode 100644
index 0000000000..577f932f8d
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/occurrence/OccurrenceActivity.kt
@@ -0,0 +1,230 @@
+/* Copyright Tusky Contributors
+ *
+ * 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.components.occurrence
+
+import android.content.Context
+import android.content.Intent
+import android.graphics.Color
+import android.os.Build
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.DividerItemDecoration
+import androidx.recyclerview.widget.ListAdapter
+import com.google.android.material.color.MaterialColors
+import com.keylesspalace.tusky.BaseActivity
+import com.keylesspalace.tusky.R
+import com.keylesspalace.tusky.databinding.ActivityOccurrencesBinding
+import com.keylesspalace.tusky.databinding.ItemOccurrenceBinding
+import com.keylesspalace.tusky.db.AccountEntity
+import com.keylesspalace.tusky.db.AppDatabase
+import com.keylesspalace.tusky.di.Injectable
+import com.keylesspalace.tusky.di.ViewModelFactory
+import com.keylesspalace.tusky.util.BindingHolder
+import com.keylesspalace.tusky.util.getDurationStringAllowMillis
+import com.keylesspalace.tusky.util.getRelativeTimeSpanString
+import com.keylesspalace.tusky.util.viewBinding
+import com.keylesspalace.tusky.util.visible
+import dagger.android.DispatchingAndroidInjector
+import dagger.android.HasAndroidInjector
+import kotlinx.coroutines.launch
+import java.text.DateFormat
+import javax.inject.Inject
+
+class OccurrenceActivity : BaseActivity(), Injectable, HasAndroidInjector {
+
+ @Inject
+ lateinit var viewModelFactory: ViewModelFactory
+
+ @Inject
+ lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector
+
+ @Inject
+ lateinit var occurrenceRepository: OccurrenceRepository
+
+ @Inject
+ lateinit var db: AppDatabase
+
+// private val viewModel: ListsViewModel by viewModels { viewModelFactory }
+
+ private val binding by viewBinding(ActivityOccurrencesBinding::inflate)
+
+ private val adapter = OccurrenceAdapter()
+
+ private var loading = false
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContentView(binding.root)
+
+ setSupportActionBar(binding.includedToolbar.toolbar)
+ supportActionBar?.apply {
+ title = getString(R.string.title_occurrences)
+ setDisplayHomeAsUpEnabled(true)
+ setDisplayShowHomeEnabled(true)
+ }
+
+ binding.occurrenceList.adapter = adapter
+ binding.occurrenceList.addItemDecoration(
+ DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
+ )
+
+ binding.swipeRefreshLayout.setOnRefreshListener { load() }
+ binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
+
+ // It's only function here so far: show "there is nothing"
+ binding.messageView.setup(
+ R.drawable.elephant_friend_empty,
+ R.string.message_empty,
+ null
+ )
+
+ load()
+ }
+
+ private fun load() {
+ if (loading) {
+ return
+ }
+
+ lifecycleScope.launch {
+ binding.swipeRefreshLayout.isRefreshing = true
+ loading = true
+
+ val occurrences = occurrenceRepository.loadAll()
+
+ adapter.submitList(occurrences)
+
+ binding.messageView.visible(occurrences.isEmpty())
+ binding.occurrenceList.visible(occurrences.isNotEmpty())
+
+ binding.swipeRefreshLayout.isRefreshing = false
+ loading = false
+ }
+ }
+
+ override fun androidInjector() = dispatchingAndroidInjector
+
+ companion object {
+ fun newIntent(context: Context) = Intent(context, OccurrenceActivity::class.java)
+ }
+
+ private object OccurrenceDiffer : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: OccurrenceEntity, newItem: OccurrenceEntity): Boolean {
+ return oldItem.id == newItem.id
+ }
+
+ override fun areContentsTheSame(oldItem: OccurrenceEntity, newItem: OccurrenceEntity): Boolean {
+ return oldItem == newItem
+ }
+ }
+
+ private inner class OccurrenceAdapter :
+ ListAdapter>(OccurrenceDiffer) {
+
+ private val dateFormat = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.SHORT)
+ private var lastAccount: AccountEntity? = null
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder {
+ return BindingHolder(ItemOccurrenceBinding.inflate(LayoutInflater.from(parent.context), parent, false))
+ }
+
+ override fun onBindViewHolder(holder: BindingHolder, position: Int) {
+ val occurrence = getItem(position)
+
+ val defaultTextColor = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary)
+
+ holder.binding.what.text = occurrence.what
+ holder.binding.what.setTextColor(
+ if (occurrence.type == OccurrenceEntity.Type.CRASH) {
+ Color.RED
+ } else {
+ defaultTextColor
+ }
+ )
+
+ holder.binding.code.text = occurrence.code?.toString() ?: ""
+ holder.binding.code.setTextColor(
+ if (occurrence.code != null && occurrence.code > 0) {
+ if (occurrence.code >= 400) {
+ baseContext.getColor(R.color.colorError)
+ } else if (occurrence.code >= 300) {
+ baseContext.getColor(R.color.colorWarning)
+ } else {
+ baseContext.getColor(R.color.colorSuccess)
+ }
+ } else {
+ defaultTextColor
+ }
+ )
+
+ holder.binding.whenDate.text =
+ getRelativeTimeSpanString(this@OccurrenceActivity.applicationContext, occurrence.startedAt.time, System.currentTimeMillis())
+ //dateFormat.format(occurrence.startedAt)
+ // TODO or AbsoluteTimeFormatter?
+
+ // TODO how does one get the current locale /and/or format numbers here?
+ val currentLocale = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ resources.configuration.locales[0]
+ } else {
+ resources.configuration.locale
+ }
+
+ var duration = ""
+ var durationMs = 0L
+ if (occurrence.finishedAt != null) {
+ durationMs = occurrence.finishedAt.time - occurrence.startedAt.time
+ duration = getDurationStringAllowMillis(currentLocale, durationMs)
+ }
+ holder.binding.duration.text = duration
+ holder.binding.duration.setTextColor(
+ if (durationMs >= 1000) {
+ baseContext.getColor(R.color.colorBad)
+ } else if (durationMs >= 400) {
+ baseContext.getColor(R.color.colorWarning)
+ } else {
+ baseContext.getColor(R.color.colorSuccess)
+ }
+ )
+
+ holder.binding.who.text = if (occurrence.accountId != null) {
+ val account = getAccount(occurrence.accountId)
+ account?.displayName ?: ""
+ } else {
+ ""
+ }
+
+ holder.binding.trace.visible(occurrence.callTrace.isNotEmpty())
+ holder.binding.trace.text = OccurrenceEntity.reduceTrace(occurrence.callTrace)
+
+ // TODO cache some objects here? For example different helper objects (locale, number format, ...)
+ }
+
+ private fun getAccount(accountId: Long): AccountEntity? {
+ if (lastAccount?.id == accountId) {
+ return lastAccount
+ }
+
+ lastAccount = db.accountDao().get(accountId)
+
+ return lastAccount
+ }
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/occurrence/OccurrenceEntity.kt b/app/src/main/java/com/keylesspalace/tusky/components/occurrence/OccurrenceEntity.kt
new file mode 100644
index 0000000000..0908fe7628
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/occurrence/OccurrenceEntity.kt
@@ -0,0 +1,72 @@
+/* Copyright Tusky Contributors
+ *
+ * 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.components.occurrence
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import androidx.room.TypeConverters
+import com.keylesspalace.tusky.db.Converters
+import java.util.*
+
+@Entity
+@TypeConverters(Converters::class)
+data class OccurrenceEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long = 0,
+ val accountId: Long? = null,
+ val type: Type,
+ val what: String,
+ val startedAt: Date, // TODO or use LocalDateTime (or Long)?
+ val finishedAt: Date? = null,
+ val code: Int? = null,
+ val callTrace: Array,
+) {
+ companion object {
+ fun reduceTrace(stackTrace: Array): String {
+ // TODO conditions/transforms here are a bit arbitrary...
+ // TODO probably keep at least the last non-Tusky location in the stack; and/or keep the information that some entries were removed
+
+ var tuskyTrace = stackTrace.filter { it.className.startsWith("com.keylesspalace.tusky") && !it.methodName.contains("intercept") }
+ if (tuskyTrace.size > 3) {
+ tuskyTrace = tuskyTrace.subList(0, 3)
+ }
+
+ return tuskyTrace.joinToString("<") { reduceClassName(it.className) + "." + it.methodName + "():" + it.lineNumber }
+ }
+
+ private fun reduceClassName(className: String): String {
+ return className.substringAfter("com.keylesspalace.tusky.")
+
+// if (!className.contains('.')) {
+// return className
+// }
+//
+// val parts = className.split('.')
+//
+// return parts.subList(parts.size-2, parts.size).joinToString(".")
+ }
+ }
+
+ enum class Type {
+ APICALL,
+ CRASH;
+
+ companion object {
+ fun fromString(type: String): Type {
+ return values().first { it.name.equals(type, true) }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/occurrence/OccurrenceRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/occurrence/OccurrenceRepository.kt
new file mode 100644
index 0000000000..76466b29fe
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/occurrence/OccurrenceRepository.kt
@@ -0,0 +1,112 @@
+package com.keylesspalace.tusky.components.occurrence
+
+import android.util.Log
+import com.keylesspalace.tusky.db.AccountManager
+import com.keylesspalace.tusky.db.AppDatabase
+import kotlinx.coroutines.runBlocking
+import java.util.Calendar
+import javax.inject.Inject
+import kotlin.math.min
+
+class OccurrenceRepository @Inject constructor(private val db: AppDatabase, private val accountManager: AccountManager) {
+ private var lastApiCalls = HashMap(13)
+ private var apiCallsCounter = 0
+
+ private val occurrenceDao = db.occurrenceDao()
+
+ fun loadAll(): List {
+ val occurrences: List
+ runBlocking {
+ occurrences = occurrenceDao.loadAll()
+ }
+
+ return occurrences
+ }
+
+ // TODO this could/should also record warning and error logs (from "Log").
+ // However that seems not to be intercept-able? Also see commented code block in MainActivity.onCreate
+
+ fun handleApiCallStart(what: String): Long {
+
+ // TODO The account id here could be wrong (for worker tasks for example)
+
+ val occurrence = OccurrenceEntity(
+ accountId = accountManager.activeAccount?.id,
+ type = OccurrenceEntity.Type.APICALL,
+ what = what,
+ startedAt = Calendar.getInstance().time,
+ callTrace = emptyArray()
+// callTrace = Throwable().stackTrace,
+ )
+ // TODO all stack traces here have no hint where they might have originated (always ThreadPool)
+ // found kotlinx.coroutines.stacktrace.recovery but that should be on by default?
+ // There is also a kotlinx.coroutines.debug.DebugProbes. But that hangs on "install()".
+
+ val entityId: Long
+ runBlocking {
+ // TODO runBlocking is the right thing to do here?
+ entityId = occurrenceDao.insertOrReplace(occurrence)
+ }
+
+ lastApiCalls[entityId] = occurrence.copy(id = entityId)
+
+ if (++apiCallsCounter % CLEANUP_INTERVAL == 0) {
+ runBlocking {
+ occurrenceDao.cleanup(entityId - MAXIMUM_ENTRIES)
+ }
+ }
+
+ return entityId
+ }
+
+ fun handleApiCallFinish(id: Long, responseCode: Int) {
+ val startedOccurrence = lastApiCalls[id]
+
+ if (startedOccurrence == null) {
+ Log.e(TAG, "Last occurrence entity not found in handleApiCallFinish for $id")
+
+ return
+ }
+
+ val occurrence = startedOccurrence.copy(
+ finishedAt = Calendar.getInstance().time,
+ code = responseCode,
+ )
+
+ runBlocking {
+ occurrenceDao.insertOrReplace(occurrence)
+ }
+
+ lastApiCalls.remove(id)
+ // TODO that map can grow (lots of unfinished calls that are never removed)?
+ }
+
+ fun handleException(exception: Throwable) {
+ var rootCause = exception
+ while (rootCause.cause != null && rootCause != rootCause.cause) {
+ rootCause = rootCause.cause!!
+ }
+
+ val traceString = OccurrenceEntity.reduceTrace(rootCause.stackTrace)
+ var what = exception.message
+ if (what == null && traceString.isNotEmpty()) {
+ what = traceString.substring(0, min(200, traceString.length))
+ }
+
+ runBlocking {
+ occurrenceDao.insertOrReplace(OccurrenceEntity(
+ accountId = accountManager.activeAccount?.id,
+ type = OccurrenceEntity.Type.CRASH,
+ what = what ?: "CRASH",
+ startedAt = Calendar.getInstance().time,
+ callTrace = rootCause.stackTrace
+ ))
+ }
+ }
+
+ companion object {
+ private const val TAG = "OccurrenceRepository"
+ private const val CLEANUP_INTERVAL = 5
+ private const val MAXIMUM_ENTRIES = 100
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/occurrence/OccurrencesViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/occurrence/OccurrencesViewModel.kt
new file mode 100644
index 0000000000..65ff026b42
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/occurrence/OccurrencesViewModel.kt
@@ -0,0 +1,30 @@
+/* Copyright Tusky Contributors
+ *
+ * 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.components.occurrence
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.keylesspalace.tusky.network.MastodonApi
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+internal class OccurrencesViewModel @Inject constructor(private val api: MastodonApi) : ViewModel() {
+ fun load() {
+ viewModelScope.launch {
+ }
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt
index c5998f1d3c..f58e16854f 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt
@@ -15,6 +15,7 @@
package com.keylesspalace.tusky.components.timeline
+import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
@@ -77,7 +78,6 @@ import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
-import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.io.IOException
diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountDao.kt
index 218c9b8f4d..11650a9039 100644
--- a/app/src/main/java/com/keylesspalace/tusky/db/AccountDao.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountDao.kt
@@ -23,6 +23,9 @@ import androidx.room.Query
@Dao
interface AccountDao {
+ @Query("SELECT * FROM AccountEntity WHERE id = :id LIMIT 1")
+ fun get(id: Long): AccountEntity?
+
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertOrReplace(account: AccountEntity): Long
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 3225cad41b..8bed3b302d 100644
--- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java
+++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java
@@ -23,6 +23,7 @@
import com.keylesspalace.tusky.TabDataKt;
import com.keylesspalace.tusky.components.conversation.ConversationEntity;
+import com.keylesspalace.tusky.components.occurrence.OccurrenceEntity;
import java.io.File;
@@ -30,8 +31,8 @@
* DB version & declare DAO
*/
@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
- TimelineAccountEntity.class, ConversationEntity.class
- }, version = 48)
+ TimelineAccountEntity.class, ConversationEntity.class, OccurrenceEntity.class
+ }, version = 49)
public abstract class AppDatabase extends RoomDatabase {
public abstract AccountDao accountDao();
@@ -39,6 +40,7 @@ public abstract class AppDatabase extends RoomDatabase {
public abstract ConversationsDao conversationDao();
public abstract TimelineDao timelineDao();
public abstract DraftDao draftDao();
+ public abstract OccurrenceDao occurrenceDao();
public static final Migration MIGRATION_2_3 = new Migration(2, 3) {
@Override
@@ -653,4 +655,19 @@ public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `filtered` TEXT");
}
};
+
+ public static final Migration MIGRATION_48_49 = new Migration(48, 49) {
+ @Override
+ public void migrate(@NonNull SupportSQLiteDatabase database) {
+ database.execSQL("CREATE TABLE IF NOT EXISTS `OccurrenceEntity` (" +
+ "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL," +
+ "`accountId` INTEGER," +
+ "`type` TEXT NOT NULL," +
+ "`what` TEXT NOT NULL," +
+ "`startedAt` INTEGER NOT NULL," +
+ "`finishedAt` INTEGER," +
+ "`code` INTEGER," +
+ "`callTrace` TEXT NOT NULL)");
+ }
+ };
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt
index 6ef9425452..7a5202703f 100644
--- a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt
@@ -21,6 +21,7 @@ import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.keylesspalace.tusky.TabData
import com.keylesspalace.tusky.components.conversation.ConversationAccountEntity
+import com.keylesspalace.tusky.components.occurrence.OccurrenceEntity
import com.keylesspalace.tusky.createTabDataFromId
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji
@@ -175,4 +176,25 @@ class Converters @Inject constructor(
fun jsonToFilterResultList(filterResultListJson: String?): List? {
return gson.fromJson(filterResultListJson, object : TypeToken>() {}.type)
}
+
+ // TODO this (simple enum <-> string) does not work automatically?
+ @TypeConverter
+ fun occurrenceTypeToString(type: OccurrenceEntity.Type): String {
+ return type.name
+ }
+
+ @TypeConverter
+ fun stringToOccurrenceType(type: String): OccurrenceEntity.Type {
+ return OccurrenceEntity.Type.fromString(type)
+ }
+
+ @TypeConverter
+ fun stackTraceToJson(stackTrace: Array): String {
+ return gson.toJson(stackTrace)
+ }
+
+ @TypeConverter
+ fun jsonToStackTrace(stackTraceJson: String): Array {
+ return gson.fromJson(stackTraceJson, object : TypeToken>() {}.type)
+ }
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/db/OccurrenceDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/OccurrenceDao.kt
new file mode 100644
index 0000000000..c50c978287
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/db/OccurrenceDao.kt
@@ -0,0 +1,43 @@
+/* Copyright Tusky Contributors
+ *
+ * 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.db
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import com.keylesspalace.tusky.components.occurrence.OccurrenceEntity
+
+@Dao
+interface OccurrenceDao {
+ @Query("SELECT * FROM OccurrenceEntity WHERE id = :id LIMIT 1")
+ fun get(id: Long): OccurrenceEntity?
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertOrReplace(one: OccurrenceEntity): Long
+
+// @Query("SELECT * FROM OccurrenceEntity WHERE accountId = :accountId ORDER BY id ASC")
+// fun pagingSource(accountId: Long): PagingSource
+
+ @Query("SELECT * FROM OccurrenceEntity ORDER BY startedAt DESC")
+ suspend fun loadAll(): List
+
+// @Query("DELETE FROM OccurrenceEntity WHERE id = :id")
+// suspend fun delete(id: Int)
+
+ @Query("DELETE FROM OccurrenceEntity WHERE id < :maxId")
+ suspend fun cleanup(maxId: Long)
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt
index 2ceb97213b..6a8e806581 100644
--- a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt
@@ -21,6 +21,7 @@ import com.keylesspalace.tusky.EditProfileActivity
import com.keylesspalace.tusky.LicenseActivity
import com.keylesspalace.tusky.ListsActivity
import com.keylesspalace.tusky.MainActivity
+import com.keylesspalace.tusky.components.occurrence.OccurrenceActivity
import com.keylesspalace.tusky.SplashActivity
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.TabPreferenceActivity
@@ -132,4 +133,7 @@ abstract class ActivitiesModule {
@ContributesAndroidInjector
abstract fun contributesEditFilterActivity(): EditFilterActivity
+
+ @ContributesAndroidInjector
+ abstract fun contributesOccurrencesActivity(): OccurrenceActivity
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt
index bc2c7d7537..5b9a35e966 100644
--- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt
@@ -68,7 +68,7 @@ class AppModule {
AppDatabase.MIGRATION_38_39, AppDatabase.MIGRATION_39_40, AppDatabase.MIGRATION_40_41,
AppDatabase.MIGRATION_41_42, AppDatabase.MIGRATION_42_43, AppDatabase.MIGRATION_43_44,
AppDatabase.MIGRATION_44_45, AppDatabase.MIGRATION_45_46, AppDatabase.MIGRATION_46_47,
- AppDatabase.MIGRATION_47_48
+ AppDatabase.MIGRATION_47_48, AppDatabase.MIGRATION_48_49
)
.build()
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt
index 03b4ad3943..701f78918c 100644
--- a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt
@@ -26,6 +26,8 @@ import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.json.Rfc3339DateJsonAdapter
import com.keylesspalace.tusky.network.InstanceSwitchAuthInterceptor
+import com.keylesspalace.tusky.components.occurrence.LogToDbInterceptor
+import com.keylesspalace.tusky.components.occurrence.OccurrenceRepository
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.MediaUploadApi
import com.keylesspalace.tusky.settings.PrefKeys.HTTP_PROXY_ENABLED
@@ -68,7 +70,8 @@ class NetworkModule {
fun providesHttpClient(
accountManager: AccountManager,
context: Context,
- preferences: SharedPreferences
+ preferences: SharedPreferences,
+ occurrenceRespository: OccurrenceRepository
): OkHttpClient {
val httpProxyEnabled = preferences.getBoolean(HTTP_PROXY_ENABLED, false)
val httpServer = preferences.getNonNullString(HTTP_PROXY_SERVER, "")
@@ -105,6 +108,7 @@ class NetworkModule {
addInterceptor(InstanceSwitchAuthInterceptor(accountManager))
if (BuildConfig.DEBUG) {
addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC })
+ addInterceptor(LogToDbInterceptor(occurrenceRespository))
}
}
.build()
diff --git a/app/src/main/java/com/keylesspalace/tusky/util/TimestampUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/TimestampUtils.kt
index a6717ed4fe..4c11b49f17 100644
--- a/app/src/main/java/com/keylesspalace/tusky/util/TimestampUtils.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/util/TimestampUtils.kt
@@ -18,6 +18,8 @@ package com.keylesspalace.tusky.util
import android.content.Context
import com.keylesspalace.tusky.R
+import java.text.NumberFormat
+import java.util.*
import kotlin.math.abs
private const val SECOND_IN_MILLIS: Long = 1000
@@ -79,6 +81,17 @@ fun getRelativeTimeSpanString(context: Context, then: Long, now: Long): String {
return context.getString(format, span)
}
+fun getDurationStringAllowMillis(locale: Locale, durationMs: Long): String {
+ val formatter = NumberFormat.getInstance(locale)
+ formatter.maximumFractionDigits = 1
+
+ return if (abs(durationMs) < SECOND_IN_MILLIS) {
+ formatter.format(durationMs) + "ms"
+ } else {
+ formatter.format(durationMs / 1000.0f) + "s"
+ }
+}
+
fun formatPollDuration(context: Context, then: Long, now: Long): String {
var span = then - now
if (span < 0) {
diff --git a/app/src/main/res/layout/activity_occurrences.xml b/app/src/main/res/layout/activity_occurrences.xml
new file mode 100644
index 0000000000..09fe377a78
--- /dev/null
+++ b/app/src/main/res/layout/activity_occurrences.xml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_occurrence.xml b/app/src/main/res/layout/item_occurrence.xml
new file mode 100644
index 0000000000..3189a7a6a2
--- /dev/null
+++ b/app/src/main/res/layout/item_occurrence.xml
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-night/theme_colors.xml b/app/src/main/res/values-night/theme_colors.xml
index 57f15799b3..66e772884b 100644
--- a/app/src/main/res/values-night/theme_colors.xml
+++ b/app/src/main/res/values-night/theme_colors.xml
@@ -28,7 +28,11 @@
@color/white
@color/tusky_grey_10
-
- #00731B
- #DF0000
+ @color/tusky_green
+ @color/tusky_red
+
+ @color/tusky_green
+ @color/tusky_orange_light
+ @color/tusky_magenta
+ @color/tusky_red
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 79a16f803c..91cabe795e 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -6,7 +6,10 @@
#fab207
#19a341
#25d069
- #DF1553
+ #148033
+ #df15c1
+ #df1553
+ #ed3b70
#fff
#000
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 8aa90ccaf9..666561cd1d 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -812,4 +812,7 @@
you follow.\n\nTo explore accounts you can either discover them in one of the other timelines.
For example the local timeline of your instance [iconics gmd_group]. Or you can search them
by name [iconics gmd_search]; for example search for Tusky to find our Mastodon account.
+
+ Occurrences (event log)
+ Occurrences
diff --git a/app/src/main/res/values/theme_colors.xml b/app/src/main/res/values/theme_colors.xml
index 32f2727fd0..dea4a75598 100644
--- a/app/src/main/res/values/theme_colors.xml
+++ b/app/src/main/res/values/theme_colors.xml
@@ -28,7 +28,11 @@
@color/tusky_grey_20
@color/white
-
- #CCFFD8
- #FFC0C0
+ @color/tusky_green
+ @color/tusky_red_light
+
+ @color/tusky_green_dark
+ @color/tusky_orange
+ @color/tusky_magenta
+ @color/tusky_red