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