From 0a4117eeed62310de3cf6cd287936e8b06c1d052 Mon Sep 17 00:00:00 2001 From: Conny Duck Date: Tue, 8 Oct 2024 19:04:50 +0200 Subject: [PATCH 01/17] Rename .java to .kt --- .../keylesspalace/tusky/{BaseActivity.java => BaseActivity.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename app/src/main/java/com/keylesspalace/tusky/{BaseActivity.java => BaseActivity.kt} (100%) diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.kt similarity index 100% rename from app/src/main/java/com/keylesspalace/tusky/BaseActivity.java rename to app/src/main/java/com/keylesspalace/tusky/BaseActivity.kt From eae29b416a52de0fc7fbeaf5e932e9757b92b336 Mon Sep 17 00:00:00 2001 From: Conny Duck Date: Wed, 9 Oct 2024 16:22:50 +0200 Subject: [PATCH 02/17] Make AccountManager async and add a ViewModel for MainActivity --- .../66.json | 1346 +++++++++++++++++ .../com/keylesspalace/tusky/BaseActivity.kt | 357 ++--- .../tusky/BottomSheetActivity.kt | 27 +- .../com/keylesspalace/tusky/MainActivity.kt | 262 +--- .../com/keylesspalace/tusky/MainViewModel.kt | 223 +++ .../java/com/keylesspalace/tusky/TabData.kt | 2 +- .../tusky/TabPreferenceActivity.kt | 16 +- .../keylesspalace/tusky/ViewMediaActivity.kt | 9 +- .../keylesspalace/tusky/appstore/Events.kt | 1 - .../announcements/AnnouncementsViewModel.kt | 2 +- .../tusky/components/login/LoginActivity.kt | 16 +- .../NotificationsRemoteMediator.kt | 5 +- .../notifications/NotificationsViewModel.kt | 5 +- .../preference/AccountPreferencesFragment.kt | 30 +- .../NotificationPreferencesFragment.kt | 37 +- .../preference/PreferencesFragment.kt | 7 +- .../NotificationFetcher.kt | 5 +- .../PushNotificationHelper.kt | 96 +- .../viewmodel/CachedTimelineRemoteMediator.kt | 12 +- .../viewmodel/CachedTimelineViewModel.kt | 11 +- .../keylesspalace/tusky/db/AccountManager.kt | 165 +- .../keylesspalace/tusky/db/AppDatabase.java | 5 +- .../com/keylesspalace/tusky/db/DraftsAlert.kt | 8 +- .../keylesspalace/tusky/db/dao/AccountDao.kt | 9 +- .../tusky/db/entity/AccountEntity.kt | 122 +- .../keylesspalace/tusky/di/StorageModule.kt | 1 - .../tusky/interfaces/FabFragment.kt | 22 - .../tusky/network/MastodonApi.kt | 4 +- ...NotificationBlockStateBroadcastReceiver.kt | 4 +- .../settings/AccountPreferenceDataStore.kt | 22 +- .../tusky/viewmodel/EditProfileViewModel.kt | 7 +- app/src/main/res/values/strings.xml | 5 - 32 files changed, 2080 insertions(+), 763 deletions(-) create mode 100644 app/schemas/com.keylesspalace.tusky.db.AppDatabase/66.json create mode 100644 app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/interfaces/FabFragment.kt diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/66.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/66.json new file mode 100644 index 0000000000..ae04564c38 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/66.json @@ -0,0 +1,1346 @@ +{ + "formatVersion": 1, + "database": { + "version": 66, + "identityHash": "a17a9b196abd59db5104b46ea19c4d10", + "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, `profileHeaderUrl` TEXT NOT NULL DEFAULT '', `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, `defaultReplyPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL DEFAULT 0, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `notificationMarkerId` TEXT NOT NULL DEFAULT '0', `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL, `lastVisibleHomeTimelineStatusId` TEXT, `locked` INTEGER NOT NULL DEFAULT 0, `hasDirectMessageBadge` INTEGER NOT NULL DEFAULT 0, `isShowHomeBoosts` INTEGER NOT NULL, `isShowHomeReplies` INTEGER NOT NULL, `isShowHomeSelfBoosts` INTEGER 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": "profileHeaderUrl", + "columnName": "profileHeaderUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "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": "defaultReplyPrivacy", + "columnName": "defaultReplyPrivacy", + "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, + "defaultValue": "0" + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationMarkerId", + "columnName": "notificationMarkerId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'0'" + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastVisibleHomeTimelineStatusId", + "columnName": "lastVisibleHomeTimelineStatusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hasDirectMessageBadge", + "columnName": "hasDirectMessageBadge", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isShowHomeBoosts", + "columnName": "isShowHomeBoosts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShowHomeReplies", + "columnName": "isShowHomeReplies", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShowHomeSelfBoosts", + "columnName": "isShowHomeSelfBoosts", + "affinity": "INTEGER", + "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, `translationEnabled` INTEGER, `filterV2Supported` INTEGER NOT NULL DEFAULT false, 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 + }, + { + "fieldPath": "translationEnabled", + "columnName": "translationEnabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filterV2Supported", + "columnName": "filterV2Supported", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `tuskyAccountId` INTEGER NOT NULL, `authorServerId` TEXT NOT NULL, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT NOT NULL, `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 NOT NULL, `mentions` TEXT NOT NULL, `tags` TEXT NOT NULL, `application` TEXT, `poll` TEXT, `muted` INTEGER NOT NULL, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT NOT NULL, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`authorServerId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) 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": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "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": true + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "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": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_tuskyAccountId", + "unique": false, + "columnNames": [ + "authorServerId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_tuskyAccountId` ON `${TABLE_NAME}` (`authorServerId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `tuskyAccountId` 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`, `tuskyAccountId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "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", + "tuskyAccountId" + ] + }, + "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": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `type` TEXT, `id` TEXT NOT NULL, `accountId` TEXT, `statusId` TEXT, `reportId` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`accountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`reportId`, `tuskyAccountId`) REFERENCES `NotificationReportEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reportId", + "columnName": "reportId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loading", + "columnName": "loading", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_accountId_tuskyAccountId", + "unique": false, + "columnNames": [ + "accountId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_accountId_tuskyAccountId` ON `${TABLE_NAME}` (`accountId`, `tuskyAccountId`)" + }, + { + "name": "index_NotificationEntity_statusId_tuskyAccountId", + "unique": false, + "columnNames": [ + "statusId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_statusId_tuskyAccountId` ON `${TABLE_NAME}` (`statusId`, `tuskyAccountId`)" + }, + { + "name": "index_NotificationEntity_reportId_tuskyAccountId", + "unique": false, + "columnNames": [ + "reportId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_reportId_tuskyAccountId` ON `${TABLE_NAME}` (`reportId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "accountId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + }, + { + "table": "TimelineStatusEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "statusId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + }, + { + "table": "NotificationReportEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "reportId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "NotificationReportEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `category` TEXT NOT NULL, `statusIds` TEXT, `createdAt` INTEGER NOT NULL, `targetAccountId` TEXT, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`targetAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statusIds", + "columnName": "statusIds", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetAccountId", + "columnName": "targetAccountId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_NotificationReportEntity_targetAccountId_tuskyAccountId", + "unique": false, + "columnNames": [ + "targetAccountId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationReportEntity_targetAccountId_tuskyAccountId` ON `${TABLE_NAME}` (`targetAccountId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "targetAccountId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "HomeTimelineEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `statusId` TEXT, `reblogAccountId` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`reblogAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loading", + "columnName": "loading", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_HomeTimelineEntity_statusId_tuskyAccountId", + "unique": false, + "columnNames": [ + "statusId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_statusId_tuskyAccountId` ON `${TABLE_NAME}` (`statusId`, `tuskyAccountId`)" + }, + { + "name": "index_HomeTimelineEntity_reblogAccountId_tuskyAccountId", + "unique": false, + "columnNames": [ + "reblogAccountId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_reblogAccountId_tuskyAccountId` ON `${TABLE_NAME}` (`reblogAccountId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineStatusEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "statusId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + }, + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "reblogAccountId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + } + ], + "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, 'a17a9b196abd59db5104b46ea19c4d10')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.kt b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.kt index 2231e7ceaa..f1171feedd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.kt @@ -1,4 +1,4 @@ -/* Copyright 2017 Andrew Dawson +/* Copyright 2024 Tusky Contributors * * This file is a part of Tusky. * @@ -12,133 +12,120 @@ * * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ - -package com.keylesspalace.tusky; - -import android.app.ActivityManager; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.res.Configuration; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.graphics.Color; -import android.os.Bundle; -import android.util.Log; -import android.view.MenuItem; -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.appcompat.app.AppCompatActivity; -import androidx.lifecycle.ViewModelProvider; - -import com.google.android.material.color.MaterialColors; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.google.android.material.snackbar.Snackbar; -import com.keylesspalace.tusky.adapter.AccountSelectionAdapter; -import com.keylesspalace.tusky.components.login.LoginActivity; -import com.keylesspalace.tusky.db.entity.AccountEntity; -import com.keylesspalace.tusky.db.AccountManager; -import com.keylesspalace.tusky.di.PreferencesEntryPoint; -import com.keylesspalace.tusky.interfaces.AccountSelectionListener; -import com.keylesspalace.tusky.settings.AppTheme; -import com.keylesspalace.tusky.settings.PrefKeys; -import com.keylesspalace.tusky.util.ActivityConstants; -import com.keylesspalace.tusky.util.ActivityExtensions; -import com.keylesspalace.tusky.util.ThemeUtils; - -import java.util.List; - -import javax.inject.Inject; - -import static com.keylesspalace.tusky.settings.PrefKeys.APP_THEME; - -import dagger.hilt.EntryPoints; +package com.keylesspalace.tusky + +import android.app.ActivityManager.TaskDescription +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.content.SharedPreferences +import android.graphics.BitmapFactory +import android.graphics.Color +import android.os.Bundle +import android.util.Log +import android.view.MenuItem +import android.view.View +import androidx.annotation.StringRes +import androidx.annotation.StyleRes +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.ViewModelProvider.Factory +import androidx.lifecycle.lifecycleScope +import com.google.android.material.color.MaterialColors +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.MainActivity.Companion.redirectIntent +import com.keylesspalace.tusky.adapter.AccountSelectionAdapter +import com.keylesspalace.tusky.components.login.LoginActivity +import com.keylesspalace.tusky.components.login.LoginActivity.Companion.getIntent +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.entity.AccountEntity +import com.keylesspalace.tusky.di.PreferencesEntryPoint +import com.keylesspalace.tusky.interfaces.AccountSelectionListener +import com.keylesspalace.tusky.settings.AppTheme +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.ActivityConstants +import com.keylesspalace.tusky.util.isBlack +import com.keylesspalace.tusky.util.overrideActivityTransitionCompat +import dagger.hilt.EntryPoints +import javax.inject.Inject +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch /** * All activities inheriting from BaseActivity must be annotated with @AndroidEntryPoint */ -public abstract class BaseActivity extends AppCompatActivity { - - public static final String OPEN_WITH_SLIDE_IN = "OPEN_WITH_SLIDE_IN"; - - private static final String TAG = "BaseActivity"; - +abstract class BaseActivity : AppCompatActivity() { @Inject - @NonNull - public AccountManager accountManager; + lateinit var accountManager: AccountManager @Inject - @NonNull - public SharedPreferences preferences; + lateinit var preferences: SharedPreferences /** * Allows overriding the default ViewModelProvider.Factory for testing purposes. */ - @Nullable - public ViewModelProvider.Factory viewModelProviderFactory = null; + var viewModelProviderFactory: Factory? = null - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) if (activityTransitionWasRequested()) { - ActivityExtensions.overrideActivityTransitionCompat( - this, - ActivityConstants.OVERRIDE_TRANSITION_OPEN, - R.anim.activity_open_enter, - R.anim.activity_open_exit - ); - ActivityExtensions.overrideActivityTransitionCompat( - this, - ActivityConstants.OVERRIDE_TRANSITION_CLOSE, - R.anim.activity_close_enter, - R.anim.activity_close_exit - ); + overrideActivityTransitionCompat( + ActivityConstants.OVERRIDE_TRANSITION_OPEN, + R.anim.activity_open_enter, + R.anim.activity_open_exit + ) + overrideActivityTransitionCompat( + ActivityConstants.OVERRIDE_TRANSITION_CLOSE, + R.anim.activity_close_enter, + R.anim.activity_close_exit + ) } /* There isn't presently a way to globally change the theme of a whole application at * runtime, just individual activities. So, each activity has to set its theme before any * views are created. */ - String theme = preferences.getString(APP_THEME, AppTheme.DEFAULT.getValue()); - Log.d("activeTheme", theme); - if (ThemeUtils.isBlack(getResources().getConfiguration(), theme)) { - setTheme(R.style.TuskyBlackTheme); - } else if (this instanceof MainActivity) { + val theme = preferences.getString(PrefKeys.APP_THEME, AppTheme.DEFAULT.value) + if (isBlack(resources.configuration, theme)) { + setTheme(R.style.TuskyBlackTheme) + } else if (this is MainActivity) { // Replace the SplashTheme of MainActivity - setTheme(R.style.TuskyTheme); + setTheme(R.style.TuskyTheme) } /* set the taskdescription programmatically, the theme would turn it blue */ - String appName = getString(R.string.app_name); - Bitmap appIcon = BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher); - int recentsBackgroundColor = MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurface, Color.BLACK); + val appName = getString(R.string.app_name) + val appIcon = BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher) + val recentsBackgroundColor = MaterialColors.getColor( + this, + com.google.android.material.R.attr.colorSurface, + Color.BLACK + ) - setTaskDescription(new ActivityManager.TaskDescription(appName, appIcon, recentsBackgroundColor)); + setTaskDescription(TaskDescription(appName, appIcon, recentsBackgroundColor)) - int style = textStyle(preferences.getString(PrefKeys.STATUS_TEXT_SIZE, "medium")); - getTheme().applyStyle(style, true); + val style = textStyle(preferences.getString(PrefKeys.STATUS_TEXT_SIZE, "medium")) + getTheme().applyStyle(style, true) - if(requiresLogin()) { - redirectIfNotLoggedIn(); + if (requiresLogin()) { + redirectIfNotLoggedIn() } } - private boolean activityTransitionWasRequested() { - return getIntent().getBooleanExtra(OPEN_WITH_SLIDE_IN, false); + private fun activityTransitionWasRequested(): Boolean { + return intent.getBooleanExtra(OPEN_WITH_SLIDE_IN, false) } - @Override - protected void attachBaseContext(Context newBase) { + override fun attachBaseContext(newBase: Context) { // injected preferences not yet available at this point of the lifecycle - SharedPreferences preferences = EntryPoints.get(newBase.getApplicationContext(), PreferencesEntryPoint.class).preferences(); + val preferences = + EntryPoints.get(newBase.applicationContext, PreferencesEntryPoint::class.java) + .preferences() // Scale text in the UI from PrefKeys.UI_TEXT_SCALE_RATIO - float uiScaleRatio = preferences.getFloat(PrefKeys.UI_TEXT_SCALE_RATIO, 100F); + val uiScaleRatio = preferences.getFloat(PrefKeys.UI_TEXT_SCALE_RATIO, 100f) - Configuration configuration = newBase.getResources().getConfiguration(); + val configuration = newBase.resources.configuration // Adjust `fontScale` in the configuration. // @@ -150,7 +137,7 @@ public abstract class BaseActivity extends AppCompatActivity { // changes to the base context. It does contain contain any changes to the font scale from // "Settings > Display > Font size" in the device settings, so scaling performed here // is in addition to any scaling in the device settings. - Configuration appConfiguration = newBase.getApplicationContext().getResources().getConfiguration(); + val appConfiguration = newBase.applicationContext.resources.configuration // This only adjusts the fonts, anything measured in `dp` is unaffected by this. // You can try to adjust `densityDpi` as shown in the commented out code below. This @@ -162,133 +149,117 @@ public abstract class BaseActivity extends AppCompatActivity { // // val displayMetrics = appContext.resources.displayMetrics // configuration.densityDpi = ((displayMetrics.densityDpi * uiScaleRatio).toInt()) - configuration.fontScale = appConfiguration.fontScale * uiScaleRatio / 100F; + configuration.fontScale = appConfiguration.fontScale * uiScaleRatio / 100f - Context fontScaleContext = newBase.createConfigurationContext(configuration); + val fontScaleContext = newBase.createConfigurationContext(configuration) - super.attachBaseContext(fontScaleContext); + super.attachBaseContext(fontScaleContext) } - @NonNull - @Override - public ViewModelProvider.Factory getDefaultViewModelProviderFactory() { - final ViewModelProvider.Factory factory = viewModelProviderFactory; - return (factory != null) ? factory : super.getDefaultViewModelProviderFactory(); - } + override val defaultViewModelProviderFactory: Factory + get() = viewModelProviderFactory ?: super.defaultViewModelProviderFactory - protected boolean requiresLogin() { - return true; - } + protected open fun requiresLogin(): Boolean = true - private static int textStyle(String name) { - int style; - switch (name) { - case "smallest": - style = R.style.TextSizeSmallest; - break; - case "small": - style = R.style.TextSizeSmall; - break; - case "medium": - default: - style = R.style.TextSizeMedium; - break; - case "large": - style = R.style.TextSizeLarge; - break; - case "largest": - style = R.style.TextSizeLargest; - break; + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + onBackPressedDispatcher.onBackPressed() + return true } - return style; + return super.onOptionsItemSelected(item) } - @Override - public boolean onOptionsItemSelected(@NonNull MenuItem item) { - if (item.getItemId() == android.R.id.home) { - getOnBackPressedDispatcher().onBackPressed(); - return true; - } - return super.onOptionsItemSelected(item); - } - - protected void redirectIfNotLoggedIn() { - AccountEntity account = accountManager.getActiveAccount(); - if (account == null) { - Intent intent = LoginActivity.getIntent(this, LoginActivity.MODE_DEFAULT); - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK); - startActivity(intent); - finish(); - } - } + private fun redirectIfNotLoggedIn() { + val currentAccounts = accountManager.accounts - protected void showErrorDialog(@Nullable View anyView, @StringRes int descriptionId, @StringRes int actionId, @Nullable View.OnClickListener listener) { - if (anyView != null) { - Snackbar bar = Snackbar.make(anyView, getString(descriptionId), Snackbar.LENGTH_SHORT); - bar.setAction(actionId, listener); - bar.show(); - } + if (currentAccounts.isEmpty()) { + println("redirecting to Login") + val intent = getIntent(this@BaseActivity, LoginActivity.MODE_DEFAULT) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) + finish() + } } - public void showAccountChooserDialog(@Nullable CharSequence dialogTitle, boolean showActiveAccount, @NonNull AccountSelectionListener listener) { - List accounts = accountManager.getAllAccountsOrderedByActive(); - AccountEntity activeAccount = accountManager.getActiveAccount(); - - switch(accounts.size()) { - case 1: - listener.onAccountSelected(activeAccount); - return; - case 2: - if (!showActiveAccount) { - for (AccountEntity account : accounts) { - if (activeAccount != account) { - listener.onAccountSelected(account); - return; - } + fun showAccountChooserDialog( + dialogTitle: CharSequence?, + showActiveAccount: Boolean, + listener: AccountSelectionListener + ) { + val accounts = accountManager.getAllAccountsOrderedByActive().toMutableList() + val activeAccount = accountManager.activeAccount + + when (accounts.size) { + 1 -> { + listener.onAccountSelected(activeAccount!!) + return + } + 2 -> if (!showActiveAccount) { + for (account in accounts) { + if (activeAccount !== account) { + listener.onAccountSelected(account) + return } } - break; + } } - if (!showActiveAccount && activeAccount != null) { - accounts.remove(activeAccount); + accounts.remove(activeAccount) } - AccountSelectionAdapter adapter = new AccountSelectionAdapter( + val adapter = AccountSelectionAdapter( this, preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) - ); - adapter.addAll(accounts); + ) + adapter.addAll(accounts) - new MaterialAlertDialogBuilder(this) + MaterialAlertDialogBuilder(this) .setTitle(dialogTitle) - .setAdapter(adapter, (dialogInterface, index) -> listener.onAccountSelected(accounts.get(index))) - .show(); + .setAdapter(adapter) { _: DialogInterface?, index: Int -> + listener.onAccountSelected(accounts[index]) + } + .show() } - public @Nullable String getOpenAsText() { - List accounts = accountManager.getAllAccountsOrderedByActive(); - switch (accounts.size()) { - case 0: - case 1: - return null; - case 2: - for (AccountEntity account : accounts) { - if (account != accountManager.getActiveAccount()) { - return String.format(getString(R.string.action_open_as), account.getFullName()); + val openAsText: String? + get() { + val accounts = accountManager.getAllAccountsOrderedByActive() + when (accounts.size) { + 0, 1 -> return null + 2 -> { + for (account in accounts) { + if (account !== accountManager.activeAccount) { + return getString(R.string.action_open_as, account.fullName) + } } + return null } - return null; - default: - return String.format(getString(R.string.action_open_as), "…"); + + else -> return getString(R.string.action_open_as, "…") + } } - } - public void openAsAccount(@NonNull String url, @NonNull AccountEntity account) { - accountManager.setActiveAccount(account.getId()); - Intent intent = MainActivity.redirectIntent(this, account.getId(), url); + fun openAsAccount(url: String, account: AccountEntity) { + lifecycleScope.launch { + accountManager.setActiveAccount(account.id) + val intent = redirectIntent(this@BaseActivity, account.id, url) - startActivity(intent); - finish(); + startActivity(intent) + finish() + } + } + + companion object { + const val OPEN_WITH_SLIDE_IN = "OPEN_WITH_SLIDE_IN" + + @StyleRes + private fun textStyle(name: String?): Int = when (name) { + "smallest" -> R.style.TextSizeSmallest + "small" -> R.style.TextSizeSmall + "medium" -> R.style.TextSizeMedium + "large" -> R.style.TextSizeLarge + "largest" -> R.style.TextSizeLargest + else -> R.style.TextSizeMedium + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt index 73c87cf2e8..8f3d192782 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt @@ -41,17 +41,22 @@ import kotlinx.coroutines.launch abstract class BottomSheetActivity : BaseActivity() { - lateinit var bottomSheet: BottomSheetBehavior var searchUrl: String? = null @Inject lateinit var mastodonApi: MastodonApi - override fun onPostCreate(savedInstanceState: Bundle?) { - super.onPostCreate(savedInstanceState) + open fun viewUrl( + url: String, + lookupFallbackBehavior: PostLookupFallbackBehavior = PostLookupFallbackBehavior.OPEN_IN_BROWSER + ) { + if (!looksLikeMastodonUrl(url)) { + openLink(url) + return + } val bottomSheetLayout: LinearLayout = findViewById(R.id.item_status_bottom_sheet) - bottomSheet = BottomSheetBehavior.from(bottomSheetLayout) + val bottomSheet = BottomSheetBehavior.from(bottomSheetLayout) bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN bottomSheet.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { override fun onStateChanged(bottomSheet: View, newState: Int) { @@ -62,16 +67,6 @@ abstract class BottomSheetActivity : BaseActivity() { override fun onSlide(bottomSheet: View, slideOffset: Float) {} }) - } - - open fun viewUrl( - url: String, - lookupFallbackBehavior: PostLookupFallbackBehavior = PostLookupFallbackBehavior.OPEN_IN_BROWSER - ) { - if (!looksLikeMastodonUrl(url)) { - openLink(url) - return - } lifecycleScope.launch { mastodonApi.search( @@ -177,11 +172,11 @@ abstract class BottomSheetActivity : BaseActivity() { } private fun showQuerySheet() { - bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED + // bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED } private fun hideQuerySheet() { - bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN + // bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN } } diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index af2ba7ac31..a961bf6f3a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -59,6 +59,7 @@ import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.target.FixedSizeDrawable import com.bumptech.glide.request.transition.Transition import com.google.android.material.R as materialR +import androidx.activity.viewModels import com.google.android.material.color.MaterialColors import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.tabs.TabLayout @@ -68,11 +69,11 @@ import com.keylesspalace.tusky.appstore.AnnouncementReadEvent import com.keylesspalace.tusky.appstore.CacheUpdater import com.keylesspalace.tusky.appstore.ConversationsLoadingEvent import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.MainTabsChangedEvent import com.keylesspalace.tusky.appstore.NewNotificationsEvent import com.keylesspalace.tusky.appstore.NotificationsLoadingEvent import com.keylesspalace.tusky.appstore.ProfileEditedEvent import com.keylesspalace.tusky.components.account.AccountActivity +import com.keylesspalace.tusky.components.account.AccountViewModel import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity import com.keylesspalace.tusky.components.compose.ComposeActivity @@ -85,9 +86,9 @@ import com.keylesspalace.tusky.components.search.SearchActivity import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper import com.keylesspalace.tusky.components.systemnotifications.disableAllNotifications import com.keylesspalace.tusky.components.systemnotifications.enablePushNotificationsWithFallback -import com.keylesspalace.tusky.components.systemnotifications.showMigrationNoticeIfNecessary import com.keylesspalace.tusky.components.trending.TrendingActivity import com.keylesspalace.tusky.databinding.ActivityMainBinding +import com.keylesspalace.tusky.db.ActiveAccountDelegate import com.keylesspalace.tusky.db.DraftsAlert import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.di.ApplicationScope @@ -96,7 +97,6 @@ import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.interfaces.AccountSelectionListener import com.keylesspalace.tusky.interfaces.ActionButtonActivity -import com.keylesspalace.tusky.interfaces.FabFragment import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.pager.MainPagerAdapter import com.keylesspalace.tusky.settings.PrefKeys @@ -144,10 +144,16 @@ import com.mikepenz.materialdrawer.widget.AccountHeaderView import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.migration.OptionalInject import de.c1710.filemojicompat_ui.helpers.EMOJI_PREFERENCE +import hilt_aggregated_deps._dagger_hilt_android_internal_lifecycle_HiltWrapper_HiltViewModelFactory_ViewModelModule import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.zip import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking @OptionalInject @AndroidEntryPoint @@ -168,12 +174,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { @Inject lateinit var developerToolsUseCase: DeveloperToolsUseCase - @Inject - lateinit var shareShortcutHelper: ShareShortcutHelper - - @Inject - @ApplicationScope - lateinit var externalScope: CoroutineScope + private val viewModel: MainViewModel by viewModels() private val binding by viewBinding(ActivityMainBinding::inflate) @@ -183,8 +184,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { private var onTabSelectedListener: OnTabSelectedListener? = null - private var unreadAnnouncementsCount = 0 - // We need to know if the emoji pack has been changed private var selectedEmojiPack: String? = null @@ -222,6 +221,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { ) } + selectedEmojiPack = preferences.getString(EMOJI_PREFERENCE, "") + var showNotificationTab = false // check for savedInstanceState in order to not handle intent events more than once @@ -257,8 +258,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { binding.mainToolbar.show() } - loadDrawerAvatar(activeAccount.profilePictureUrl, true) - addMenuProvider(this) binding.viewPager.reduceSwipeSensitivity() @@ -274,88 +273,46 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { ) ) - /* Fetch user info while we're doing other things. This has to be done after setting up the - * drawer, though, because its callback touches the header in the drawer. */ - fetchUserInfo() + lifecycleScope.launch { + viewModel.accounts.collect(::updateProfiles) + } - fetchAnnouncements() + lifecycleScope.launch { + viewModel.unreadAnnouncementsCount.collect(::updateAnnouncementsBadge) + } // Initialise the tab adapter and set to viewpager. Fragments appear to be leaked if the // adapter changes over the life of the viewPager (the adapter, not its contents), so set // the initial list of tabs to empty, and set the full list later in setupTabs(). See // https://github.com/tuskyapp/Tusky/issues/3251 for details. - tabAdapter = MainPagerAdapter(emptyList(), this) + tabAdapter = MainPagerAdapter(emptyList(), this@MainActivity) binding.viewPager.adapter = tabAdapter - setupTabs(showNotificationTab) - lifecycleScope.launch { - eventHub.events.collect { event -> - when (event) { - is ProfileEditedEvent -> onFetchUserInfoSuccess(event.newProfileData) - is MainTabsChangedEvent -> { - refreshMainDrawerItems( - addSearchButton = hideTopToolbar, - addTrendingTagsButton = !event.newTabs.hasTab(TRENDING_TAGS), - addTrendingStatusesButton = !event.newTabs.hasTab(TRENDING_STATUSES) - ) - - setupTabs(false) - } - is AnnouncementReadEvent -> { - unreadAnnouncementsCount-- - updateAnnouncementsBadge() - } - is NewNotificationsEvent -> { - directMessageTab?.let { - if (event.accountId == activeAccount.accountId) { - val hasDirectMessageNotification = - event.notifications.any { - it.type == Notification.Type.MENTION && it.status?.visibility == Status.Visibility.DIRECT - } - - if (hasDirectMessageNotification) { - showDirectMessageBadge(true) - } - } - } - } - is NotificationsLoadingEvent -> { - if (event.accountId == activeAccount.accountId) { - showDirectMessageBadge(false) - } - } - is ConversationsLoadingEvent -> { - if (event.accountId == activeAccount.accountId) { - showDirectMessageBadge(false) - } - } - } - } + viewModel.tabs.collect(::setupTabs) } - externalScope.launch(Dispatchers.IO) { - // Flush old media that was cached for sharing - deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Tusky")) + lifecycleScope.launch { + viewModel.showDirectMessagesBadge.collect { showBadge -> + updateDirectMessageBadge(showBadge) + } } - selectedEmojiPack = preferences.getString(EMOJI_PREFERENCE, "") - - onBackPressedDispatcher.addCallback(this, onBackPressedCallback) + onBackPressedDispatcher.addCallback(this@MainActivity, onBackPressedCallback) if ( Build.VERSION.SDK_INT >= 33 && - ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ContextCompat.checkSelfPermission(this@MainActivity, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED ) { ActivityCompat.requestPermissions( - this, + this@MainActivity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1 ) } // "Post failed" dialog should display in this activity - draftsAlert.observeInContext(this, true) + draftsAlert.observeInContext(this@MainActivity, true) } override fun onNewIntent(intent: Intent) { @@ -451,18 +408,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { return false } - private fun showDirectMessageBadge(showBadge: Boolean) { - directMessageTab?.let { tab -> - tab.badge?.isVisible = showBadge - - // TODO a bit cumbersome (also for resetting) - lifecycleScope.launch(Dispatchers.IO) { - if (activeAccount.hasDirectMessageBadge != showBadge) { - activeAccount.hasDirectMessageBadge = showBadge - accountManager.saveAccount(activeAccount) - } - } - } + private fun updateDirectMessageBadge(showBadge: Boolean) { + directMessageTab?.badge?.isVisible = showBadge } override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { @@ -816,14 +763,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { isEnabled = true iconicsIcon = GoogleMaterial.Icon.gmd_developer_mode onClick = { - buildDeveloperToolsDialog().show() + showDeveloperToolsDialog() } } ) } } - private fun buildDeveloperToolsDialog(): AlertDialog { + private fun showDeveloperToolsDialog(): AlertDialog { return MaterialAlertDialogBuilder(this) .setTitle("Developer Tools") .setItems( @@ -841,14 +788,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { } } } - .create() + .show() } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(binding.mainDrawer.saveInstanceState(outState)) } - private fun setupTabs(selectNotificationTab: Boolean) { + private fun setupTabs(tabs: List) { val activeTabLayout = if (preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top") == "bottom") { val actionBarSize = getDimension(this, androidx.appcompat.R.attr.actionBarSize) val fabMargin = resources.getDimensionPixelSize(R.dimen.fabMargin) @@ -865,8 +812,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { // Save the previous tab so it can be restored later val previousTab = tabAdapter.tabs.getOrNull(binding.viewPager.currentItem) - val tabs = activeAccount.tabPreferences - // Detach any existing mediator before changing tab contents and attaching a new mediator tabLayoutMediator?.detach() @@ -889,12 +834,13 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { directMessageTab = tab } }.also { it.attach() } + updateDirectMessageBadge(viewModel.showDirectMessagesBadge.value) // Selected tab is either // - Notification tab (if appropriate) // - The previously selected tab (if it hasn't been removed) // - Left-most tab - val position = if (selectNotificationTab) { + val position = if (false) { tabs.indexOfFirst { it.id == NOTIFICATIONS } } else { previousTab?.let { tabs.indexOfFirst { it == previousTab } } @@ -917,15 +863,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { binding.mainToolbar.title = tab.contentDescription - refreshComposeButtonState(tabAdapter, tab.position) - if (tab == directMessageTab) { - tab.badge?.isVisible = false - - if (activeAccount.hasDirectMessageBadge) { - activeAccount.hasDirectMessageBadge = false - accountManager.saveAccount(activeAccount) - } + viewModel.dismissDirectMessagesBadge() } } @@ -934,10 +873,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { override fun onTabReselected(tab: TabLayout.Tab) { val fragment = tabAdapter.getFragment(tab.position) if (fragment is ReselectableFragment) { - (fragment as ReselectableFragment).onReselect() + fragment.onReselect() } - - refreshComposeButtonState(tabAdapter, tab.position) } }.also { activeTabLayout.addOnTabSelectedListener(it) @@ -951,22 +888,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { ) as? ReselectableFragment )?.onReselect() } - - updateProfiles() - } - - private fun refreshComposeButtonState(adapter: MainPagerAdapter, tabPosition: Int) { - adapter.getFragment(tabPosition)?.also { fragment -> - if (fragment is FabFragment) { - if (fragment.isFabVisible()) { - binding.composeButton.show() - } else { - binding.composeButton.hide() - } - } else { - binding.composeButton.show() - } - } } private fun handleProfileClick(profile: IProfile, current: Boolean): Boolean { @@ -991,18 +912,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { private fun changeAccount( newSelectedId: Long, forward: Intent?, - ) { - cacheUpdater.stop() - accountManager.setActiveAccount(newSelectedId) - val intent = Intent(this, MainActivity::class.java) - if (forward != null) { - intent.type = forward.type - intent.action = forward.action - intent.putExtras(forward) + ) = lifecycleScope.launch { + cacheUpdater.stop() + accountManager.setActiveAccount(newSelectedId) + val intent = Intent(this@MainActivity, MainActivity::class.java) + if (forward != null) { + intent.type = forward.type + intent.action = forward.action + intent.putExtras(forward) + } + startActivity(intent) + finish() } - finish() - startActivity(intent) - } private fun logout() { MaterialAlertDialogBuilder(this) @@ -1030,49 +951,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { .show() } - private fun fetchUserInfo() = lifecycleScope.launch { - mastodonApi.accountVerifyCredentials().fold( - { userInfo -> - onFetchUserInfoSuccess(userInfo) - }, - { throwable -> - Log.e(TAG, "Failed to fetch user info. " + throwable.message) - } - ) - } - - private fun onFetchUserInfoSuccess(me: Account) { - Glide.with(header.accountHeaderBackground) - .asBitmap() - .load(me.header) - .into(header.accountHeaderBackground) - - loadDrawerAvatar(me.avatar, false) - - accountManager.updateAccount(activeAccount, me) - NotificationHelper.createNotificationChannelsForAccount(activeAccount, this) - - // Setup push notifications - showMigrationNoticeIfNecessary( - this, - binding.mainCoordinatorLayout, - binding.composeButton, - accountManager - ) - if (NotificationHelper.areNotificationsEnabled(this, accountManager)) { - lifecycleScope.launch { - enablePushNotificationsWithFallback(this@MainActivity, mastodonApi, accountManager) - } - } else { - disableAllNotifications(this, accountManager) - } - - updateProfiles() - shareShortcutHelper.updateShortcuts() - } - @SuppressLint("CheckResult") - private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) { + private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean = true) { val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false) val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) @@ -1087,6 +967,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { binding.mainToolbar } + println("loadDrawerAvatar ${activeToolbar.navigationIcon}") + + val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size) if (animateAvatars) { @@ -1158,22 +1041,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { } } - private fun fetchAnnouncements() { - lifecycleScope.launch { - mastodonApi.listAnnouncements(false) - .fold( - { announcements -> - unreadAnnouncementsCount = announcements.count { !it.read } - updateAnnouncementsBadge() - }, - { throwable -> - Log.w(TAG, "Failed to fetch announcements.", throwable) - } - ) - } - } - - private fun updateAnnouncementsBadge() { + private fun updateAnnouncementsBadge(unreadAnnouncementsCount: Int) { binding.mainDrawer.updateBadge( DRAWER_ITEM_ANNOUNCEMENTS, StringHolder( @@ -1182,12 +1050,24 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { ) } - private fun updateProfiles() { + private fun updateProfiles(accounts: List) { + if (accounts.isEmpty()) { + return + } + val activeProfile = accounts.first() + + loadDrawerAvatar(activeProfile.profilePictureUrl) + + Glide.with(header.accountHeaderBackground) + .asBitmap() + .load(activeProfile.profileHeaderUrl) + .into(header.accountHeaderBackground) + val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) val profiles: MutableList = - accountManager.getAllAccountsOrderedByActive().map { acc -> + accounts.map { acc -> ProfileDrawerItem().apply { - isSelected = acc.isActive + isSelected = acc == activeProfile nameText = acc.displayName.emojify(acc.emojis, header, animateEmojis) iconUrl = acc.profilePictureUrl isNameShown = true @@ -1205,9 +1085,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { } header.clear() header.profiles = profiles - header.setActiveProfile(activeAccount.id) + header.setActiveProfile(activeProfile.id) binding.mainToolbar.subtitle = if (accountManager.shouldDisplaySelfUsername()) { - activeAccount.fullName + activeProfile.fullName } else { null } diff --git a/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt new file mode 100644 index 0000000000..2d1cfd0353 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt @@ -0,0 +1,223 @@ +/* Copyright 2024 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 + +import android.content.Context +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.viewModelScope +import androidx.room.PrimaryKey +import at.connyduck.calladapter.networkresult.fold +import com.keylesspalace.tusky.MainActivity.Companion +import com.keylesspalace.tusky.appstore.AnnouncementReadEvent +import com.keylesspalace.tusky.appstore.ConversationsLoadingEvent +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.NewNotificationsEvent +import com.keylesspalace.tusky.appstore.NotificationsLoadingEvent +import com.keylesspalace.tusky.appstore.ProfileEditedEvent +import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper +import com.keylesspalace.tusky.components.systemnotifications.disableAllNotifications +import com.keylesspalace.tusky.components.systemnotifications.enablePushNotificationsWithFallback +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.entity.AccountEntity +import com.keylesspalace.tusky.di.ApplicationScope +import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.Resource +import com.keylesspalace.tusky.util.ShareShortcutHelper +import com.keylesspalace.tusky.util.deleteStaleCachedMedia +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@HiltViewModel +class MainViewModel @Inject constructor( + @ApplicationScope private val externalScope: CoroutineScope, + @ApplicationContext private val context: Context, + private val api: MastodonApi, + private val eventHub: EventHub, + private val accountManager: AccountManager, + private val shareShortcutHelper: ShareShortcutHelper +): ViewModel() { + + private val activeAccount = accountManager.activeAccount + + val accounts: StateFlow> = accountManager.accountsFlow + .map { accounts -> + accounts.map { account -> + AccountViewData( + id = account.id, + isActive = account.isActive, + domain = account.domain, + accountId = account.accountId, + username = account.username, + displayName = account.displayName, + profilePictureUrl = account.profilePictureUrl, + profileHeaderUrl = account.profileHeaderUrl, + emojis = account.emojis + ) + } + } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + val tabs: Flow> = accountManager.accountsFlow + .mapNotNull { accounts -> + accounts.find { activeAccount?.id == it.id }?.tabPreferences + } + .distinctUntilChanged() + + private val _unreadAnnouncementsCount = MutableStateFlow(0) + val unreadAnnouncementsCount: StateFlow = _unreadAnnouncementsCount.asStateFlow() + + val showDirectMessagesBadge: StateFlow = accountManager.accountsFlow + .map { accounts -> + accounts.find { activeAccount?.id == it.id }?.hasDirectMessageBadge == true + } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + + init { + loadAccountData() + fetchAnnouncements() + collectEvents() + deleteStaleCachedMedia() + } + + private fun loadAccountData() { + viewModelScope.launch { + api.accountVerifyCredentials().fold( + { userInfo -> + accountManager.updateAccount(activeAccount!!, userInfo) + + shareShortcutHelper.updateShortcuts() + + NotificationHelper.createNotificationChannelsForAccount(activeAccount!!, context) + + if (NotificationHelper.areNotificationsEnabled(context, accountManager)) { + viewModelScope.launch { + enablePushNotificationsWithFallback(context, api, accountManager) + } + } else { + disableAllNotifications(context, accountManager) + } + }, + { throwable -> + Log.w(TAG, "Failed to fetch user info.", throwable) + } + ) + + } + } + + private fun fetchAnnouncements() { + viewModelScope.launch { + api.announcements() + .fold( + { announcements -> + _unreadAnnouncementsCount.value = announcements.count { !it.read } + }, + { throwable -> + Log.w(TAG, "Failed to fetch announcements.", throwable) + } + ) + } + } + + private fun collectEvents() { + viewModelScope.launch { + eventHub.events.collect { event -> + when (event) { + is AnnouncementReadEvent -> { + _unreadAnnouncementsCount.value-- + } + is NewNotificationsEvent -> { + if (event.accountId == activeAccount?.accountId) { + val hasDirectMessageNotification = + event.notifications.any { + it.type == Notification.Type.MENTION && it.status?.visibility == Status.Visibility.DIRECT + } + + if (hasDirectMessageNotification) { + accountManager.updateAccount(activeAccount) { copy(hasDirectMessageBadge = true) } + } + } + } + is NotificationsLoadingEvent -> { + if (event.accountId == activeAccount?.accountId) { + accountManager.updateAccount(activeAccount) { copy(hasDirectMessageBadge = false) } + } + } + is ConversationsLoadingEvent -> { + if (event.accountId == activeAccount?.accountId) { + accountManager.updateAccount(activeAccount) { copy(hasDirectMessageBadge = false) } + } + } + } + } + } + } + + private fun deleteStaleCachedMedia() { + externalScope.launch(Dispatchers.IO) { + // Flush old media that was cached for sharing + deleteStaleCachedMedia(context.getExternalFilesDir("Tusky")) + } + } + + fun dismissDirectMessagesBadge() { + viewModelScope.launch { + accountManager.updateAccount(activeAccount!!) { copy(hasDirectMessageBadge = false) } + } + } + + companion object { + private const val TAG = "MainViewModel" + } +} + +data class AccountViewData( + val id: Long, + val isActive: Boolean, + val domain: String, + val accountId: String, + val username: String, + val displayName: String, + val profilePictureUrl: String, + val profileHeaderUrl: String, + val emojis: List +) { + val fullName: String + get() = "@$username@$domain" +} diff --git a/app/src/main/java/com/keylesspalace/tusky/TabData.kt b/app/src/main/java/com/keylesspalace/tusky/TabData.kt index caefa0dac2..9f23f74f88 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabData.kt @@ -60,7 +60,7 @@ data class TabData( override fun hashCode() = Objects.hash(id, arguments) } -fun List.hasTab(id: String): Boolean = this.find { it.id == id } != null +fun List.hasTab(id: String): Boolean = this.any { it.id == id } fun createTabDataFromId(id: String, arguments: List = emptyList()): TabData { return when (id) { diff --git a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt index 7cd0b8eacd..4d64f72460 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt @@ -38,7 +38,6 @@ import com.google.android.material.transition.MaterialContainerTransform import com.keylesspalace.tusky.adapter.ItemInteractionListener import com.keylesspalace.tusky.adapter.TabAdapter import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.MainTabsChangedEvent import com.keylesspalace.tusky.components.account.list.ListSelectionFragment import com.keylesspalace.tusky.databinding.ActivityTabPreferenceBinding import com.keylesspalace.tusky.entity.MastoList @@ -68,8 +67,6 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener, ListSelec private lateinit var touchHelper: ItemTouchHelper private lateinit var addTabAdapter: TabAdapter - private var tabsChanged = false - private val selectedItemElevation by unsafeLazy { resources.getDimension(R.dimen.selected_drag_item_elevation) } @@ -350,19 +347,8 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener, ListSelec private fun saveTabs() { accountManager.activeAccount?.let { - lifecycleScope.launch(Dispatchers.IO) { - it.tabPreferences = currentTabs - accountManager.saveAccount(it) - } - } - tabsChanged = true - } - - override fun onPause() { - super.onPause() - if (tabsChanged) { lifecycleScope.launch { - eventHub.dispatch(MainTabsChangedEvent(currentTabs)) + accountManager.updateAccount(it) { copy(tabPreferences = currentTabs)} } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt index de142c62da..596689b071 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt @@ -43,6 +43,7 @@ import androidx.lifecycle.lifecycleScope import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.widget.ViewPager2 import com.bumptech.glide.Glide +import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BuildConfig.APPLICATION_ID import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity import com.keylesspalace.tusky.databinding.ActivityViewMediaBinding @@ -91,11 +92,9 @@ class ViewMediaActivity : if (isGranted) { downloadMedia() } else { - showErrorDialog( - binding.toolbar, - R.string.error_media_download_permission, - R.string.action_retry - ) { requestDownloadMedia() } + Snackbar.make(binding.toolbar, getString(R.string.error_media_download_permission), Snackbar.LENGTH_SHORT) + .setAction(R.string.action_retry) { requestDownloadMedia() } + .show() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt index 490c874bcd..e1e364a18e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt @@ -16,7 +16,6 @@ data class StatusComposedEvent(val status: Status) : Event data class StatusScheduledEvent(val scheduledStatus: ScheduledStatus) : Event data class ProfileEditedEvent(val newProfileData: Account) : Event data class PreferenceChangedEvent(val preferenceKey: String) : Event -data class MainTabsChangedEvent(val newTabs: List) : Event data class PollVoteEvent(val statusId: String, val poll: Poll) : Event data class DomainMuteEvent(val instance: String) : Event data class AnnouncementReadEvent(val announcementId: String) : Event diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt index 3834c5d97b..665ee3ce6c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt @@ -58,7 +58,7 @@ class AnnouncementsViewModel @Inject constructor( fun load() { viewModelScope.launch { _announcements.value = Loading() - mastodonApi.listAnnouncements() + mastodonApi.announcements() .fold( { _announcements.value = Success(it) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt index 6cc9097320..f66d205e89 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt @@ -83,7 +83,7 @@ class LoginActivity : BaseActivity() { if (savedInstanceState == null && BuildConfig.CUSTOM_INSTANCE.isNotBlank() && - !isAdditionalLogin() && !isAccountMigration() + !isAdditionalLogin() ) { binding.domainEditText.setText(BuildConfig.CUSTOM_INSTANCE) binding.domainEditText.setSelection(BuildConfig.CUSTOM_INSTANCE.length) @@ -95,11 +95,6 @@ class LoginActivity : BaseActivity() { clientSecret = savedInstanceState.getString(CLIENT_SECRET, "") } - if (isAccountMigration()) { - binding.domainEditText.setText(accountManager.activeAccount!!.domain) - binding.domainEditText.isEnabled = false - } - if (BuildConfig.CUSTOM_LOGO_URL.isNotBlank()) { Glide.with(binding.loginLogo) .load(BuildConfig.CUSTOM_LOGO_URL) @@ -119,7 +114,7 @@ class LoginActivity : BaseActivity() { } setSupportActionBar(binding.toolbar) - supportActionBar?.setDisplayHomeAsUpEnabled(isAdditionalLogin() || isAccountMigration()) + supportActionBar?.setDisplayHomeAsUpEnabled(isAdditionalLogin()) supportActionBar?.setDisplayShowTitleEnabled(false) } @@ -330,10 +325,6 @@ class LoginActivity : BaseActivity() { return intent.getIntExtra(LOGIN_MODE, MODE_DEFAULT) == MODE_ADDITIONAL_LOGIN } - private fun isAccountMigration(): Boolean { - return intent.getIntExtra(LOGIN_MODE, MODE_DEFAULT) == MODE_MIGRATION - } - companion object { private const val TAG = "LoginActivity" // logging tag private const val OAUTH_SCOPES = "read write follow push" @@ -345,9 +336,6 @@ class LoginActivity : BaseActivity() { const val MODE_DEFAULT = 0 const val MODE_ADDITIONAL_LOGIN = 1 - // "Migration" is used to update the OAuth scope granted to the client - const val MODE_MIGRATION = 2 - @JvmStatic fun getIntent(context: Context, mode: Int): Intent { val loginIntent = Intent(context, LoginActivity::class.java) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt index d3b71f7507..9dd7e510b6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt @@ -188,7 +188,7 @@ class NotificationsRemoteMediator( return overlappedNotifications } - private fun saveNewestNotificationId(notification: Notification) { + private suspend fun saveNewestNotificationId(notification: Notification) { val account = accountManager.activeAccount // make sure the account we are currently working with is still active if (account == activeAccount) { @@ -196,8 +196,7 @@ class NotificationsRemoteMediator( val newestNotificationId = notification.id if (lastNotificationId.isLessThan(newestNotificationId)) { Log.d(TAG, "saving newest noti id: $lastNotificationId for account ${account.id}") - account.lastNotificationId = newestNotificationId - accountManager.saveAccount(account) + accountManager.updateAccount(account) { copy(lastNotificationId = newestNotificationId) } } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt index 5e74003ecc..be9208fd36 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt @@ -139,8 +139,9 @@ class NotificationsViewModel @Inject constructor( fun updateNotificationFilters(newFilters: Set) { if (newFilters != _excludes.value) { viewModelScope.launch { - account.notificationsFilter = serialize(newFilters) - accountManager.saveAccount(account) + accountManager.updateAccount(account) { + copy(notificationsFilter = serialize(newFilters)) + } remoteMediator.excludes = newFilters db.notificationsDao().cleanupNotifications(account.id, 0) refreshTrigger.value++ diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt index e86286df11..de081b1c11 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt @@ -37,8 +37,6 @@ import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.domainblocks.DomainBlocksActivity import com.keylesspalace.tusky.components.filters.FiltersActivity import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity -import com.keylesspalace.tusky.components.login.LoginActivity -import com.keylesspalace.tusky.components.systemnotifications.currentAccountNeedsMigration import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Status @@ -155,18 +153,6 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() { } } - if (currentAccountNeedsMigration(accountManager)) { - preference { - setTitle(R.string.title_migration_relogin) - setIcon(R.drawable.ic_logout) - setOnPreferenceClickListener { - val intent = LoginActivity.getIntent(context, LoginActivity.MODE_MIGRATION) - activity?.startActivityWithSlideInAnimation(intent) - true - } - } - } - preference { setTitle(R.string.pref_title_timeline_filters) setIcon(R.drawable.ic_filter_24dp) @@ -215,9 +201,8 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() { setOnPreferenceChangeListener { _, newValue -> val newVisibility = DefaultReplyVisibility.fromStringValue(newValue as String) setIcon(getIconForVisibility(newVisibility.toVisibilityOr(activeAccount.defaultPostPrivacy))) - activeAccount.defaultReplyPrivacy = newVisibility - accountManager.saveAccount(activeAccount) viewLifecycleOwner.lifecycleScope.launch { + accountManager.updateAccount(activeAccount) { copy(defaultReplyPrivacy = newVisibility) } eventHub.dispatch(PreferenceChangedEvent(key)) } true @@ -336,11 +321,14 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() { mastodonApi.accountUpdateSource(visibility, sensitive, language) .fold({ account: Account -> accountManager.activeAccount?.let { - it.defaultPostPrivacy = account.source?.privacy - ?: Status.Visibility.PUBLIC - it.defaultMediaSensitivity = account.source?.sensitive ?: false - it.defaultPostLanguage = language.orEmpty() - accountManager.saveAccount(it) + accountManager.updateAccount(it) { + copy( + defaultPostPrivacy = account.source?.privacy + ?: Status.Visibility.PUBLIC, + defaultMediaSensitivity = account.source?.sensitive ?: false, + defaultPostLanguage = language.orEmpty() + ) + } } }, { t -> Log.e("AccountPreferences", "failed updating settings on server", t) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt index 4b8eb03f6a..9812addd36 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt @@ -16,6 +16,7 @@ package com.keylesspalace.tusky.components.preference import android.os.Bundle +import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceFragmentCompat import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper @@ -27,6 +28,7 @@ import com.keylesspalace.tusky.settings.preferenceCategory import com.keylesspalace.tusky.settings.switchPreference import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject +import kotlinx.coroutines.launch @AndroidEntryPoint class NotificationPreferencesFragment : PreferenceFragmentCompat() { @@ -44,7 +46,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationsEnabled setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsEnabled = newValue as Boolean } + updateAccount { copy(notificationsEnabled = newValue as Boolean) } if (NotificationHelper.areNotificationsEnabled(context, accountManager)) { NotificationHelper.enablePullNotifications(context) } else { @@ -64,7 +66,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationsFollowed setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsFollowed = newValue as Boolean } + updateAccount { copy(notificationsFollowed = newValue as Boolean) } true } } @@ -75,7 +77,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationsFollowRequested setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsFollowRequested = newValue as Boolean } + updateAccount { copy(notificationsFollowRequested = newValue as Boolean) } true } } @@ -86,7 +88,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationsReblogged setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsReblogged = newValue as Boolean } + updateAccount { copy(notificationsReblogged = newValue as Boolean) } true } } @@ -97,7 +99,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationsFavorited setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsFavorited = newValue as Boolean } + updateAccount { copy(notificationsFavorited = newValue as Boolean) } true } } @@ -108,7 +110,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationsPolls setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsPolls = newValue as Boolean } + updateAccount { copy(notificationsPolls = newValue as Boolean) } true } } @@ -119,7 +121,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationsSubscriptions setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsSubscriptions = newValue as Boolean } + updateAccount { copy(notificationsSubscriptions = newValue as Boolean) } true } } @@ -130,7 +132,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationsSignUps setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsSignUps = newValue as Boolean } + updateAccount { copy(notificationsSignUps = newValue as Boolean) } true } } @@ -141,7 +143,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationsUpdates setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsUpdates = newValue as Boolean } + updateAccount { copy(notificationsUpdates = newValue as Boolean) } true } } @@ -152,7 +154,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationsReports setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsReports = newValue as Boolean } + updateAccount { copy(notificationsReports = newValue as Boolean) } true } } @@ -168,7 +170,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationSound setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationSound = newValue as Boolean } + updateAccount { copy(notificationSound = newValue as Boolean) } true } } @@ -179,7 +181,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationVibration setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationVibration = newValue as Boolean } + updateAccount { copy(notificationVibration = newValue as Boolean) } true } } @@ -190,7 +192,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationLight setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationLight = newValue as Boolean } + updateAccount { copy(notificationLight = newValue as Boolean) } true } } @@ -198,10 +200,11 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { } } - private inline fun updateAccount(changer: (AccountEntity) -> Unit) { - accountManager.activeAccount?.let { account -> - changer(account) - accountManager.saveAccount(account) + private fun updateAccount(changer: AccountEntity.() -> AccountEntity) { + viewLifecycleOwner.lifecycleScope.launch { + accountManager.activeAccount?.let { account -> + accountManager.updateAccount(account, changer) + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt index 3ca6ca6a0b..e4bb4a520e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt @@ -16,6 +16,7 @@ package com.keylesspalace.tusky.components.preference import android.os.Bundle +import androidx.lifecycle.lifecycleScope import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import com.keylesspalace.tusky.R @@ -40,6 +41,7 @@ import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import dagger.hilt.android.AndroidEntryPoint import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference import javax.inject.Inject +import kotlinx.coroutines.launch @AndroidEntryPoint class PreferencesFragment : PreferenceFragmentCompat() { @@ -279,8 +281,9 @@ class PreferencesFragment : PreferenceFragmentCompat() { notificationFilter.remove(Notification.Type.REBLOG) } - account.notificationsFilter = serialize(notificationFilter) - accountManager.saveAccount(account) + lifecycleScope.launch { + accountManager.updateAccount(account) { copy(notificationsFilter = serialize(notificationFilter)) } + } } true } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationFetcher.kt b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationFetcher.kt index 13ea0d9090..9b8833eafd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationFetcher.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationFetcher.kt @@ -134,8 +134,6 @@ class NotificationFetcher @Inject constructor( notificationManager, account ) - - accountManager.saveAccount(account) } catch (e: Exception) { Log.e(TAG, "Error while fetching notifications", e) } @@ -221,8 +219,7 @@ class NotificationFetcher @Inject constructor( domain = account.domain, notificationsLastReadId = newMarkerId ) - account.notificationMarkerId = newMarkerId - accountManager.saveAccount(account) + accountManager.updateAccount(account) { copy(notificationMarkerId = newMarkerId) } } return notifications diff --git a/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/PushNotificationHelper.kt b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/PushNotificationHelper.kt index de9bce2697..2100680d93 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/PushNotificationHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/PushNotificationHelper.kt @@ -40,59 +40,6 @@ import org.unifiedpush.android.connector.UnifiedPush private const val TAG = "PushNotificationHelper" -private const val KEY_MIGRATION_NOTICE_DISMISSED = "migration_notice_dismissed" - -private fun anyAccountNeedsMigration(accountManager: AccountManager): Boolean = - accountManager.accounts.any(::accountNeedsMigration) - -private fun accountNeedsMigration(account: AccountEntity): Boolean = - !account.oauthScopes.contains("push") - -fun currentAccountNeedsMigration(accountManager: AccountManager): Boolean = - accountManager.activeAccount?.let(::accountNeedsMigration) ?: false - -fun showMigrationNoticeIfNecessary( - context: Context, - parent: View, - anchorView: View?, - accountManager: AccountManager -) { - // No point showing anything if we cannot enable it - if (!isUnifiedPushAvailable(context)) return - if (!anyAccountNeedsMigration(accountManager)) return - - val pm = PreferenceManager.getDefaultSharedPreferences(context) - if (pm.getBoolean(KEY_MIGRATION_NOTICE_DISMISSED, false)) return - - Snackbar.make(parent, R.string.tips_push_notification_migration, Snackbar.LENGTH_INDEFINITE) - .setAnchorView(anchorView) - .setAction( - R.string.action_details - ) { showMigrationExplanationDialog(context, accountManager) } - .show() -} - -private fun showMigrationExplanationDialog(context: Context, accountManager: AccountManager) { - MaterialAlertDialogBuilder(context).apply { - if (currentAccountNeedsMigration(accountManager)) { - setMessage(R.string.dialog_push_notification_migration) - setPositiveButton(R.string.title_migration_relogin) { _, _ -> - context.startActivity( - LoginActivity.getIntent(context, LoginActivity.MODE_MIGRATION) - ) - } - } else { - setMessage(R.string.dialog_push_notification_migration_other_accounts) - } - setNegativeButton(R.string.action_dismiss) { dialog, _ -> - val pm = PreferenceManager.getDefaultSharedPreferences(context) - pm.edit().putBoolean(KEY_MIGRATION_NOTICE_DISMISSED, true).apply() - dialog.dismiss() - } - show() - } -} - private suspend fun enableUnifiedPushNotificationsForAccount( context: Context, api: MastodonApi, @@ -123,18 +70,15 @@ fun disableUnifiedPushNotificationsForAccount(context: Context, account: Account fun isUnifiedPushNotificationEnabledForAccount(account: AccountEntity): Boolean = account.unifiedPushUrl.isNotEmpty() -private fun isUnifiedPushAvailable(context: Context): Boolean = +fun isUnifiedPushAvailable(context: Context): Boolean = UnifiedPush.getDistributors(context).isNotEmpty() -fun canEnablePushNotifications(context: Context, accountManager: AccountManager): Boolean = - isUnifiedPushAvailable(context) && !anyAccountNeedsMigration(accountManager) - suspend fun enablePushNotificationsWithFallback( context: Context, api: MastodonApi, accountManager: AccountManager ) { - if (!canEnablePushNotifications(context, accountManager)) { + if (!isUnifiedPushAvailable(context)) { // No UP distributors NotificationHelper.enablePullNotifications(context) return @@ -208,12 +152,15 @@ suspend fun registerUnifiedPushEndpoint( }.onSuccess { Log.d(TAG, "UnifiedPush registration succeeded for account ${account.id}") - account.pushPubKey = keyPair.pubkey - account.pushPrivKey = keyPair.privKey - account.pushAuth = auth - account.pushServerKey = it.serverKey - account.unifiedPushUrl = endpoint - accountManager.saveAccount(account) + accountManager.updateAccount(account) { + copy( + pushPubKey = keyPair.pubkey, + pushPrivKey = keyPair.privKey, + pushAuth = auth, + pushServerKey = it.serverKey, + unifiedPushUrl = endpoint + ) + } } } @@ -231,9 +178,9 @@ suspend fun updateUnifiedPushSubscription( buildSubscriptionData(context, account) ).onSuccess { Log.d(TAG, "UnifiedPush subscription updated for account ${account.id}") - - account.pushServerKey = it.serverKey - accountManager.saveAccount(account) + accountManager.updateAccount(account) { + copy(pushServerKey = it.serverKey) + } } } } @@ -251,12 +198,15 @@ suspend fun unregisterUnifiedPushEndpoint( .onSuccess { Log.d(TAG, "UnifiedPush unregistration succeeded for account " + account.id) // Clear the URL in database - account.unifiedPushUrl = "" - account.pushServerKey = "" - account.pushAuth = "" - account.pushPrivKey = "" - account.pushPubKey = "" - accountManager.saveAccount(account) + accountManager.updateAccount(account) { + copy( + pushPubKey = "", + pushPrivKey = "", + pushAuth = "", + pushServerKey = "", + unifiedPushUrl = "" + ) + } } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt index 73bc5e7144..3c576236a6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -26,6 +26,7 @@ import com.keylesspalace.tusky.components.timeline.toEntity import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.db.entity.HomeTimelineData import com.keylesspalace.tusky.db.entity.HomeTimelineEntity import com.keylesspalace.tusky.db.entity.TimelineStatusEntity @@ -45,13 +46,13 @@ class CachedTimelineRemoteMediator( private val timelineDao = db.timelineDao() private val statusDao = db.timelineStatusDao() private val accountDao = db.timelineAccountDao() - private val activeAccount = accountManager.activeAccount!! + private val activeAccount = accountManager.activeAccount override suspend fun load( loadType: LoadType, state: PagingState ): MediatorResult { - if (!activeAccount.isLoggedIn()) { + if (activeAccount == null) { return MediatorResult.Success(endOfPaginationReached = true) } @@ -77,7 +78,7 @@ class CachedTimelineRemoteMediator( val statuses = statusResponse.body() if (statusResponse.isSuccessful && statuses != null) { db.withTransaction { - replaceStatusRange(statuses, state) + replaceStatusRange(statuses, state, activeAccount) } } } @@ -104,7 +105,7 @@ class CachedTimelineRemoteMediator( } db.withTransaction { - val overlappedStatuses = replaceStatusRange(statuses, state) + val overlappedStatuses = replaceStatusRange(statuses, state, activeAccount) /* In case we loaded a whole page and there was no overlap with existing statuses, we insert a placeholder because there might be even more unknown statuses */ @@ -135,7 +136,8 @@ class CachedTimelineRemoteMediator( */ private suspend fun replaceStatusRange( statuses: List, - state: PagingState + state: PagingState, + activeAccount: AccountEntity ): Int { val overlappedStatuses = if (statuses.isNotEmpty()) { timelineDao.deleteRange(activeAccount.id, statuses.last().id, statuses.first().id) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index 2685fda143..ccc24c65ad 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -268,10 +268,13 @@ class CachedTimelineViewModel @Inject constructor( } override fun saveReadingPosition(statusId: String) { - accountManager.activeAccount?.let { account -> - Log.d(TAG, "Saving position at: $statusId") - account.lastVisibleHomeTimelineStatusId = statusId - accountManager.saveAccount(account) + viewModelScope.launch { + accountManager.activeAccount?.let { account -> + Log.d(TAG, "Saving position at: $statusId") + accountManager.updateAccount(account) { + copy(lastVisibleHomeTimelineStatusId = statusId) + } + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt index c286d47b13..0ca0662394 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt @@ -17,14 +17,33 @@ package com.keylesspalace.tusky.db import android.content.SharedPreferences import android.util.Log +import androidx.room.withTransaction import com.keylesspalace.tusky.db.dao.AccountDao import com.keylesspalace.tusky.db.entity.AccountEntity +import com.keylesspalace.tusky.di.ApplicationScope import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.settings.PrefKeys +import com.mikepenz.materialdrawer.model.SecondaryDrawerItem import java.util.Locale import javax.inject.Inject import javax.inject.Singleton +import kotlin.reflect.KProperty +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking /** * This class caches the account database and handles all account related operations @@ -35,26 +54,37 @@ private const val TAG = "AccountManager" @Singleton class AccountManager @Inject constructor( - db: AppDatabase, - private val preferences: SharedPreferences + private val db: AppDatabase, + private val preferences: SharedPreferences, + @ApplicationScope private val applicationScope: CoroutineScope ) { - @Volatile - var activeAccount: AccountEntity? = null - private set - - var accounts: MutableList = mutableListOf() - private set - private val accountDao: AccountDao = db.accountDao() - init { - accounts = accountDao.loadAll().toMutableList() + val accountsFlow: StateFlow> = runBlocking { + accountDao.allAccounts() + .onEach { + Log.d(TAG, "accounts updated: $it") + } + .onCompletion { + Log.d(TAG, "accounts flow completed: $it") - activeAccount = accounts.find { acc -> acc.isActive } - ?: accounts.firstOrNull()?.also { acc -> acc.isActive = true } + } + .stateIn(CoroutineScope(applicationScope.coroutineContext + Dispatchers.IO)) } + val accounts: List + get() = accountsFlow.value + + val activeAccount: AccountEntity? + get() { + val a = accounts.firstOrNull() + Log.d(TAG, "returning active account with id ${a?.id}") + return a + } + + fun activeAccount() = ActiveAccountDelegate(this) + /** * Adds a new account and makes it the active account. * @param accessToken the access token for the new account @@ -64,19 +94,19 @@ class AccountManager @Inject constructor( * @param oauthScopes the oauth scopes granted to the account * @param newAccount the [Account] as returned by the Mastodon Api */ - fun addAccount( + suspend fun addAccount( accessToken: String, domain: String, clientId: String, clientSecret: String, oauthScopes: String, newAccount: Account - ) { + ) = db.withTransaction { activeAccount?.let { - it.isActive = false + //it.isActive = false Log.d(TAG, "addAccount: saving account with id " + it.id) - accountDao.insertOrReplace(it) + accountDao.insertOrReplace(it.copy(isActive = false)) } // check if this is a relogin with an existing account, if yes update it, otherwise create a new one val existingAccountIndex = accounts.indexOfFirst { account -> @@ -89,7 +119,7 @@ class AccountManager @Inject constructor( clientSecret = clientSecret, oauthScopes = oauthScopes, isActive = true - ).also { accounts[existingAccountIndex] = it } + ) } else { val maxAccountId = accounts.maxByOrNull { it.id }?.id ?: 0 val newAccountId = maxAccountId + 1 @@ -102,10 +132,8 @@ class AccountManager @Inject constructor( oauthScopes = oauthScopes, isActive = true, accountId = newAccount.id - ).also { accounts.add(it) } + ) } - - activeAccount = newAccountEntity updateAccount(newAccountEntity, newAccount) } @@ -114,10 +142,11 @@ class AccountManager @Inject constructor( * New accounts must be created with [addAccount] * @param account the account to save */ - fun saveAccount(account: AccountEntity) { + suspend fun updateAccount(account: AccountEntity, changer: AccountEntity.() -> AccountEntity) { if (account.id != 0L) { - Log.d(TAG, "saveAccount: saving account with id " + account.id) - accountDao.insertOrReplace(account) + // get the newest version of the account to make sure no stale data gets re-saved to db + val acc = accounts.find { it.id == account.id } ?: return + accountDao.insertOrReplace(changer(acc)) } } @@ -125,21 +154,20 @@ class AccountManager @Inject constructor( * Logs an account out by deleting all its data. * @return the new active account, or null if no other account was found */ - fun logout(account: AccountEntity): AccountEntity? { - account.logout() - - accounts.remove(account) + suspend fun logout(account: AccountEntity): AccountEntity? = db.withTransaction { accountDao.delete(account) - if (accounts.size > 0) { - accounts[0].isActive = true - activeAccount = accounts[0] - Log.d(TAG, "logActiveAccountOut: saving account with id " + accounts[0].id) - accountDao.insertOrReplace(accounts[0]) + val otherAccount = accounts.find { it.id != account.id } + if (otherAccount != null) { + val otherAccountActive = otherAccount.copy( + isActive = true + ) + Log.d(TAG, "logActiveAccountOut: saving account with id " + otherAccountActive.id) + accountDao.insertOrReplace(otherAccountActive) + otherAccountActive } else { - activeAccount = null + null } - return activeAccount } /** @@ -147,59 +175,50 @@ class AccountManager @Inject constructor( * and saves it in the database. * @param accountEntity the [AccountEntity] to update * @param account the [Account] object which the newest data from the api + * @return the updated [AccountEntity] */ - fun updateAccount(accountEntity: AccountEntity, account: Account) { - accountEntity.accountId = account.id - accountEntity.username = account.username - accountEntity.displayName = account.name - accountEntity.profilePictureUrl = account.avatar - accountEntity.defaultPostPrivacy = account.source?.privacy ?: Status.Visibility.PUBLIC - accountEntity.defaultPostLanguage = account.source?.language.orEmpty() - accountEntity.defaultMediaSensitivity = account.source?.sensitive ?: false - accountEntity.emojis = account.emojis - accountEntity.locked = account.locked + suspend fun updateAccount(accountEntity: AccountEntity, account: Account): AccountEntity { + val newAccount = accountEntity.copy( + accountId = account.id, + username = account.username, + displayName = account.name, + profilePictureUrl = account.avatar, + profileHeaderUrl = account.header, + defaultPostPrivacy = account.source?.privacy ?: Status.Visibility.PUBLIC, + defaultPostLanguage = account.source?.language.orEmpty(), + defaultMediaSensitivity = account.source?.sensitive ?: false, + emojis = account.emojis, + locked = account.locked + ) Log.d(TAG, "updateAccount: saving account with id " + accountEntity.id) - accountDao.insertOrReplace(accountEntity) + accountDao.insertOrReplace(newAccount) + return newAccount } /** * changes the active account * @param accountId the database id of the new active account */ - fun setActiveAccount(accountId: Long) { + suspend fun setActiveAccount(accountId: Long) = db.withTransaction { + Log.d(TAG, "setActiveAccount $accountId") + val newActiveAccount = accounts.find { (id) -> id == accountId - } ?: return // invalid accountId passed, do nothing + } ?: return@withTransaction // invalid accountId passed, do nothing activeAccount?.let { - Log.d(TAG, "setActiveAccount: saving account with id " + it.id) - it.isActive = false - saveAccount(it) + accountDao.insertOrReplace(it.copy(isActive = false)) } - activeAccount = newActiveAccount - - activeAccount?.let { - it.isActive = true - accountDao.insertOrReplace(it) - } + accountDao.insertOrReplace(newActiveAccount.copy(isActive = true)) } /** * @return an immutable list of all accounts in the database with the active account first */ fun getAllAccountsOrderedByActive(): List { - val accountsCopy = accounts.toMutableList() - accountsCopy.sortWith { l, r -> - when { - l.isActive && !r.isActive -> -1 - r.isActive && !l.isActive -> 1 - else -> 0 - } - } - - return accountsCopy + return accounts } /** @@ -249,3 +268,15 @@ class AccountManager @Inject constructor( return accounts.size > 1 // "disambiguate" } } + + +class ActiveAccountDelegate( + private val accountManager: AccountManager +) { + + val accountId = accountManager.activeAccount?.id + + operator fun getValue(thisRef: Any?, property: KProperty<*>): AccountEntity? { + return accountManager.accounts.find { it.id == accountId } + } +} 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 81597e00ce..aea248b84e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -62,7 +62,7 @@ }, // Note: Starting with version 54, database versions in Tusky are always even. // This is to reserve odd version numbers for use by forks. - version = 64, + version = 66, autoMigrations = { @AutoMigration(from = 48, to = 49), @AutoMigration(from = 49, to = 50, spec = AppDatabase.MIGRATION_49_50.class), @@ -70,7 +70,8 @@ @AutoMigration(from = 51, to = 52), @AutoMigration(from = 53, to = 54), // hasDirectMessageBadge in AccountEntity @AutoMigration(from = 56, to = 58), // translationEnabled in InstanceEntity/InstanceInfoEntity - @AutoMigration(from = 62, to = 64) // filterV2Available in InstanceEntity + @AutoMigration(from = 62, to = 64), // filterV2Available in InstanceEntity + @AutoMigration(from = 64, to = 66) // added profileHeaderUrl to AccountEntity } ) public abstract class AppDatabase extends RoomDatabase { diff --git a/app/src/main/java/com/keylesspalace/tusky/db/DraftsAlert.kt b/app/src/main/java/com/keylesspalace/tusky/db/DraftsAlert.kt index 1f9508622b..743ddf8476 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/DraftsAlert.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/DraftsAlert.kt @@ -38,13 +38,13 @@ import kotlinx.coroutines.launch private const val TAG = "DraftsAlert" @Singleton -class DraftsAlert @Inject constructor(db: AppDatabase) { +class DraftsAlert @Inject constructor( + db: AppDatabase, + private val accountManager: AccountManager +) { // For tracking when a media upload fails in the service private val draftDao: DraftDao = db.draftDao() - @Inject - lateinit var accountManager: AccountManager - fun observeInContext(context: T, showAlert: Boolean) where T : Context, T : LifecycleOwner { accountManager.activeAccount?.let { activeAccount -> val coroutineScope = context.lifecycleScope diff --git a/app/src/main/java/com/keylesspalace/tusky/db/dao/AccountDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/dao/AccountDao.kt index b310231734..eb38316ca9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/dao/AccountDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/dao/AccountDao.kt @@ -21,15 +21,16 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.keylesspalace.tusky.db.entity.AccountEntity +import kotlinx.coroutines.flow.Flow @Dao interface AccountDao { @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insertOrReplace(account: AccountEntity): Long + suspend fun insertOrReplace(account: AccountEntity): Long @Delete - fun delete(account: AccountEntity) + suspend fun delete(account: AccountEntity) - @Query("SELECT * FROM AccountEntity ORDER BY id ASC") - fun loadAll(): List + @Query("SELECT * FROM AccountEntity ORDER BY isActive DESC") + fun allAccounts(): Flow> } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/entity/AccountEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/entity/AccountEntity.kt index a774788124..8f7e4ebadd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/entity/AccountEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/entity/AccountEntity.kt @@ -37,86 +37,88 @@ import com.keylesspalace.tusky.settings.DefaultReplyVisibility ) @TypeConverters(Converters::class) data class AccountEntity( - @field:PrimaryKey(autoGenerate = true) var id: Long, + @field:PrimaryKey(autoGenerate = true) val id: Long, val domain: String, - var accessToken: String, + val accessToken: String, // nullable for backward compatibility - var clientId: String?, + val clientId: String?, // nullable for backward compatibility - var clientSecret: String?, - var isActive: Boolean, - var accountId: String = "", - var username: String = "", - var displayName: String = "", - var profilePictureUrl: String = "", - var notificationsEnabled: Boolean = true, - var notificationsMentioned: Boolean = true, - var notificationsFollowed: Boolean = true, - var notificationsFollowRequested: Boolean = false, - var notificationsReblogged: Boolean = true, - var notificationsFavorited: Boolean = true, - var notificationsPolls: Boolean = true, - var notificationsSubscriptions: Boolean = true, - var notificationsSignUps: Boolean = true, - var notificationsUpdates: Boolean = true, - var notificationsReports: Boolean = true, - var notificationSound: Boolean = true, - var notificationVibration: Boolean = true, - var notificationLight: Boolean = true, - var defaultPostPrivacy: Status.Visibility = Status.Visibility.PUBLIC, - var defaultReplyPrivacy: DefaultReplyVisibility = DefaultReplyVisibility.MATCH_DEFAULT_POST_VISIBILITY, - var defaultMediaSensitivity: Boolean = false, - var defaultPostLanguage: String = "", - var alwaysShowSensitiveMedia: Boolean = false, + val clientSecret: String?, + val isActive: Boolean, + val accountId: String = "", + val username: String = "", + val displayName: String = "", + val profilePictureUrl: String = "", + @ColumnInfo(defaultValue = "") val profileHeaderUrl: String = "", + val notificationsEnabled: Boolean = true, + val notificationsMentioned: Boolean = true, + val notificationsFollowed: Boolean = true, + val notificationsFollowRequested: Boolean = false, + val notificationsReblogged: Boolean = true, + val notificationsFavorited: Boolean = true, + val notificationsPolls: Boolean = true, + val notificationsSubscriptions: Boolean = true, + val notificationsSignUps: Boolean = true, + val notificationsUpdates: Boolean = true, + val notificationsReports: Boolean = true, + val notificationSound: Boolean = true, + val notificationVibration: Boolean = true, + val notificationLight: Boolean = true, + val defaultPostPrivacy: Status.Visibility = Status.Visibility.PUBLIC, + val defaultReplyPrivacy: DefaultReplyVisibility = DefaultReplyVisibility.MATCH_DEFAULT_POST_VISIBILITY, + val defaultMediaSensitivity: Boolean = false, + val defaultPostLanguage: String = "", + val alwaysShowSensitiveMedia: Boolean = false, /** True if content behind a content warning is shown by default */ - var alwaysOpenSpoiler: Boolean = false, + @ColumnInfo(defaultValue = "0") + val alwaysOpenSpoiler: Boolean = false, /** * True if the "Download media previews" preference is true. This implies * that media previews are shown as well as downloaded. */ - var mediaPreviewEnabled: Boolean = true, + val mediaPreviewEnabled: Boolean = true, /** * ID of the last notification the user read on the Notification, list, and should be restored * to view when the user returns to the list. * * May not be the ID of the most recent notification if the user has scrolled down the list. */ - var lastNotificationId: String = "0", + val lastNotificationId: String = "0", /** * ID of the most recent Mastodon notification that Tusky has fetched to show as an * Android notification. */ @ColumnInfo(defaultValue = "0") - var notificationMarkerId: String = "0", - var emojis: List = emptyList(), - var tabPreferences: List = defaultTabs(), - var notificationsFilter: String = "[\"follow_request\"]", + val notificationMarkerId: String = "0", + val emojis: List = emptyList(), + val tabPreferences: List = defaultTabs(), + val notificationsFilter: String = "[\"follow_request\"]", // Scope cannot be changed without re-login, so store it in case // the scope needs to be changed in the future - var oauthScopes: String = "", - var unifiedPushUrl: String = "", - var pushPubKey: String = "", - var pushPrivKey: String = "", - var pushAuth: String = "", - var pushServerKey: String = "", + val oauthScopes: String = "", + val unifiedPushUrl: String = "", + val pushPubKey: String = "", + val pushPrivKey: String = "", + val pushAuth: String = "", + val pushServerKey: String = "", /** * ID of the status at the top of the visible list in the home timeline when the * user navigated away. */ - var lastVisibleHomeTimelineStatusId: String? = null, + val lastVisibleHomeTimelineStatusId: String? = null, /** true if the connected Mastodon account is locked (has to manually approve all follow requests **/ @ColumnInfo(defaultValue = "0") - var locked: Boolean = false, + val locked: Boolean = false, @ColumnInfo(defaultValue = "0") - var hasDirectMessageBadge: Boolean = false, + val hasDirectMessageBadge: Boolean = false, - var isShowHomeBoosts: Boolean = true, - var isShowHomeReplies: Boolean = true, - var isShowHomeSelfBoosts: Boolean = true + val isShowHomeBoosts: Boolean = true, + val isShowHomeReplies: Boolean = true, + val isShowHomeSelfBoosts: Boolean = true ) { val identifier: String @@ -124,30 +126,4 @@ data class AccountEntity( val fullName: String get() = "@$username@$domain" - - fun logout() { - // deleting credentials so they cannot be used again - accessToken = "" - clientId = null - clientSecret = null - } - - fun isLoggedIn() = accessToken.isNotEmpty() - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as AccountEntity - - if (id == other.id) return true - return domain == other.domain && accountId == other.accountId - } - - override fun hashCode(): Int { - var result = id.hashCode() - result = 31 * result + domain.hashCode() - result = 31 * result + accountId.hashCode() - return result - } } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/StorageModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/StorageModule.kt index 5f5893b3b5..b17fdbb4e9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/StorageModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/StorageModule.kt @@ -46,7 +46,6 @@ object StorageModule { fun providesDatabase(@ApplicationContext appContext: Context, converters: Converters): AppDatabase { return Room.databaseBuilder(appContext, AppDatabase::class.java, "tuskyDB") .addTypeConverter(converters) - .allowMainThreadQueries() .addMigrations( AppDatabase.MIGRATION_2_3, AppDatabase.MIGRATION_3_4, AppDatabase.MIGRATION_4_5, AppDatabase.MIGRATION_5_6, AppDatabase.MIGRATION_6_7, AppDatabase.MIGRATION_7_8, diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/FabFragment.kt b/app/src/main/java/com/keylesspalace/tusky/interfaces/FabFragment.kt deleted file mode 100644 index 1189dd3b36..0000000000 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/FabFragment.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright 2023 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.interfaces - -interface FabFragment { - fun isFabVisible(): Boolean -} diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index 9ad49f5df6..2813d8e7e4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -607,9 +607,7 @@ interface MastodonApi { ): NetworkResult @GET("api/v1/announcements") - suspend fun listAnnouncements( - @Query("with_dismissed") withDismissed: Boolean = true - ): NetworkResult> + suspend fun announcements(): NetworkResult> @POST("api/v1/announcements/{id}/dismiss") suspend fun dismissAnnouncement(@Path("id") announcementId: String): NetworkResult diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt index 9df766bb44..fa906510ea 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt @@ -20,7 +20,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.os.Build -import com.keylesspalace.tusky.components.systemnotifications.canEnablePushNotifications +import com.keylesspalace.tusky.components.systemnotifications.isUnifiedPushAvailable import com.keylesspalace.tusky.components.systemnotifications.isUnifiedPushNotificationEnabledForAccount import com.keylesspalace.tusky.components.systemnotifications.updateUnifiedPushSubscription import com.keylesspalace.tusky.db.AccountManager @@ -45,7 +45,7 @@ class NotificationBlockStateBroadcastReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (Build.VERSION.SDK_INT < 28) return - if (!canEnablePushNotifications(context, accountManager)) return + if (!isUnifiedPushAvailable(context)) return val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceDataStore.kt b/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceDataStore.kt index d62731f010..bac5efbf39 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceDataStore.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceDataStore.kt @@ -30,18 +30,18 @@ class AccountPreferenceDataStore @Inject constructor( } override fun putBoolean(key: String, value: Boolean) { - when (key) { - PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> account.alwaysShowSensitiveMedia = value - PrefKeys.ALWAYS_OPEN_SPOILER -> account.alwaysOpenSpoiler = value - PrefKeys.MEDIA_PREVIEW_ENABLED -> account.mediaPreviewEnabled = value - PrefKeys.TAB_FILTER_HOME_BOOSTS -> account.isShowHomeBoosts = value - PrefKeys.TAB_FILTER_HOME_REPLIES -> account.isShowHomeReplies = value - PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS -> account.isShowHomeSelfBoosts = value - } - - accountManager.saveAccount(account) - externalScope.launch { + accountManager.updateAccount(account) { + when (key) { + PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> copy(alwaysShowSensitiveMedia = value) + PrefKeys.ALWAYS_OPEN_SPOILER -> copy(alwaysOpenSpoiler = value) + PrefKeys.MEDIA_PREVIEW_ENABLED -> copy(mediaPreviewEnabled = value) + PrefKeys.TAB_FILTER_HOME_BOOSTS -> copy(isShowHomeBoosts = value) + PrefKeys.TAB_FILTER_HOME_REPLIES -> copy(isShowHomeReplies = value) + PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS -> copy(isShowHomeSelfBoosts = value) + else -> this + } + } eventHub.dispatch(PreferenceChangedEvent(key)) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt index a61fe4806e..cf92650c08 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt @@ -25,6 +25,7 @@ import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.ProfileEditedEvent import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository +import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.StringField import com.keylesspalace.tusky.network.MastodonApi @@ -65,9 +66,12 @@ class EditProfileViewModel @Inject constructor( private val mastodonApi: MastodonApi, private val eventHub: EventHub, private val application: Application, + private val accountManager: AccountManager, instanceInfoRepo: InstanceInfoRepository ) : ViewModel() { + private val activeAccount = accountManager.activeAccount!! + private val _profileData = MutableStateFlow(null as Resource?) val profileData: StateFlow?> = _profileData.asStateFlow() @@ -169,8 +173,9 @@ class EditProfileViewModel @Inject constructor( diff.field4?.second?.toRequestBody(MultipartBody.FORM) ).fold( { newAccountData -> - _saveData.value = Success() + accountManager.updateAccount(activeAccount, newAccountData) eventHub.dispatch(ProfileEditedEvent(newAccountData)) + _saveData.value = Success() }, { throwable -> _saveData.value = Error(errorMessage = throwable.getServerErrorMessage()) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 59955747b6..e5d3ebdc7d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -71,7 +71,6 @@ Muted users Blocked users Hidden domains - Re-login for push notifications Follow requests Edit your profile Drafts @@ -768,10 +767,6 @@ Saving draft… - Re-login all accounts to enable push notification support. - In order to use push notifications via UnifiedPush, Tusky needs permission to subscribe to notifications on your Mastodon server. This requires a re-login to change the OAuth scopes granted to Tusky. Using the re-login option here or in "Account preferences" will preserve all of your local drafts and cache. - You have re-logged into your current account to grant push subscription permission to Tusky. However, you still have other accounts that have not been migrated this way. Switch to them and re-login one by one in order to enable UnifiedPush notifications support. - Delete this scheduled post? By logging in you agree to the rules of %1$s. From fde604a1db7b40775d6751cc4e2043b7d9fa4313 Mon Sep 17 00:00:00 2001 From: Conny Duck Date: Tue, 3 Dec 2024 20:16:35 +0100 Subject: [PATCH 03/17] fix code formatting --- .../com/keylesspalace/tusky/BaseActivity.kt | 25 ++++------ .../tusky/BottomSheetActivity.kt | 5 +- .../com/keylesspalace/tusky/MainActivity.kt | 49 +++++-------------- .../com/keylesspalace/tusky/MainViewModel.kt | 30 ++++-------- .../tusky/TabPreferenceActivity.kt | 3 +- .../keylesspalace/tusky/appstore/Events.kt | 1 - .../NotificationsRemoteMediator.kt | 2 +- .../preference/AccountPreferencesFragment.kt | 3 +- .../PushNotificationHelper.kt | 6 --- .../keylesspalace/tusky/db/AccountManager.kt | 33 +++++-------- 10 files changed, 46 insertions(+), 111 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.kt b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.kt index f1171feedd..70e9338597 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.kt @@ -22,17 +22,13 @@ import android.content.SharedPreferences import android.graphics.BitmapFactory import android.graphics.Color import android.os.Bundle -import android.util.Log import android.view.MenuItem -import android.view.View -import androidx.annotation.StringRes import androidx.annotation.StyleRes import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.ViewModelProvider.Factory import androidx.lifecycle.lifecycleScope import com.google.android.material.color.MaterialColors import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.MainActivity.Companion.redirectIntent import com.keylesspalace.tusky.adapter.AccountSelectionAdapter import com.keylesspalace.tusky.components.login.LoginActivity @@ -48,7 +44,6 @@ import com.keylesspalace.tusky.util.isBlack import com.keylesspalace.tusky.util.overrideActivityTransitionCompat import dagger.hilt.EntryPoints import javax.inject.Inject -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch /** @@ -157,7 +152,7 @@ abstract class BaseActivity : AppCompatActivity() { } override val defaultViewModelProviderFactory: Factory - get() = viewModelProviderFactory ?: super.defaultViewModelProviderFactory + get() = viewModelProviderFactory ?: super.defaultViewModelProviderFactory protected open fun requiresLogin(): Boolean = true @@ -170,15 +165,15 @@ abstract class BaseActivity : AppCompatActivity() { } private fun redirectIfNotLoggedIn() { - val currentAccounts = accountManager.accounts - - if (currentAccounts.isEmpty()) { - println("redirecting to Login") - val intent = getIntent(this@BaseActivity, LoginActivity.MODE_DEFAULT) - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) - startActivity(intent) - finish() - } + val currentAccounts = accountManager.accounts + + if (currentAccounts.isEmpty()) { + println("redirecting to Login") + val intent = getIntent(this@BaseActivity, LoginActivity.MODE_DEFAULT) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) + finish() + } } fun showAccountChooserDialog( diff --git a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt index 8f3d192782..2baeab0bfe 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt @@ -17,7 +17,6 @@ package com.keylesspalace.tusky import android.content.Context import android.content.Intent -import android.os.Bundle import android.view.View import android.widget.LinearLayout import android.widget.Toast @@ -172,11 +171,11 @@ abstract class BottomSheetActivity : BaseActivity() { } private fun showQuerySheet() { - // bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED + // bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED } private fun hideQuerySheet() { - // bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN + // bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN } } diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index a961bf6f3a..f73287af50 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -40,6 +40,7 @@ import android.view.MenuItem.SHOW_AS_ACTION_NEVER import android.view.View import android.widget.ImageView import androidx.activity.OnBackPressedCallback +import androidx.activity.viewModels import androidx.appcompat.app.AlertDialog import androidx.appcompat.content.res.AppCompatResources import androidx.coordinatorlayout.widget.CoordinatorLayout @@ -52,28 +53,20 @@ import androidx.core.view.forEach import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import androidx.viewpager2.widget.MarginPageTransformer -import at.connyduck.calladapter.networkresult.fold import com.bumptech.glide.Glide import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.target.FixedSizeDrawable import com.bumptech.glide.request.transition.Transition import com.google.android.material.R as materialR -import androidx.activity.viewModels import com.google.android.material.color.MaterialColors import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout.OnTabSelectedListener import com.google.android.material.tabs.TabLayoutMediator -import com.keylesspalace.tusky.appstore.AnnouncementReadEvent import com.keylesspalace.tusky.appstore.CacheUpdater -import com.keylesspalace.tusky.appstore.ConversationsLoadingEvent import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.NewNotificationsEvent -import com.keylesspalace.tusky.appstore.NotificationsLoadingEvent -import com.keylesspalace.tusky.appstore.ProfileEditedEvent import com.keylesspalace.tusky.components.account.AccountActivity -import com.keylesspalace.tusky.components.account.AccountViewModel import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity import com.keylesspalace.tusky.components.compose.ComposeActivity @@ -83,18 +76,11 @@ import com.keylesspalace.tusky.components.login.LoginActivity import com.keylesspalace.tusky.components.preference.PreferencesActivity import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity import com.keylesspalace.tusky.components.search.SearchActivity -import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper -import com.keylesspalace.tusky.components.systemnotifications.disableAllNotifications -import com.keylesspalace.tusky.components.systemnotifications.enablePushNotificationsWithFallback import com.keylesspalace.tusky.components.trending.TrendingActivity import com.keylesspalace.tusky.databinding.ActivityMainBinding -import com.keylesspalace.tusky.db.ActiveAccountDelegate import com.keylesspalace.tusky.db.DraftsAlert import com.keylesspalace.tusky.db.entity.AccountEntity -import com.keylesspalace.tusky.di.ApplicationScope -import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Notification -import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.interfaces.AccountSelectionListener import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.ReselectableFragment @@ -103,8 +89,6 @@ import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.usecase.DeveloperToolsUseCase import com.keylesspalace.tusky.usecase.LogoutUsecase import com.keylesspalace.tusky.util.ActivityConstants -import com.keylesspalace.tusky.util.ShareShortcutHelper -import com.keylesspalace.tusky.util.deleteStaleCachedMedia import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.getDimension import com.keylesspalace.tusky.util.getParcelableExtraCompat @@ -144,16 +128,8 @@ import com.mikepenz.materialdrawer.widget.AccountHeaderView import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.migration.OptionalInject import de.c1710.filemojicompat_ui.helpers.EMOJI_PREFERENCE -import hilt_aggregated_deps._dagger_hilt_android_internal_lifecycle_HiltWrapper_HiltViewModelFactory_ViewModelModule import javax.inject.Inject -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.zip import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking @OptionalInject @AndroidEntryPoint @@ -913,17 +889,17 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { newSelectedId: Long, forward: Intent?, ) = lifecycleScope.launch { - cacheUpdater.stop() - accountManager.setActiveAccount(newSelectedId) - val intent = Intent(this@MainActivity, MainActivity::class.java) - if (forward != null) { - intent.type = forward.type - intent.action = forward.action - intent.putExtras(forward) - } - startActivity(intent) - finish() + cacheUpdater.stop() + accountManager.setActiveAccount(newSelectedId) + val intent = Intent(this@MainActivity, MainActivity::class.java) + if (forward != null) { + intent.type = forward.type + intent.action = forward.action + intent.putExtras(forward) } + startActivity(intent) + finish() + } private fun logout() { MaterialAlertDialogBuilder(this) @@ -967,9 +943,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { binding.mainToolbar } - println("loadDrawerAvatar ${activeToolbar.navigationIcon}") - - val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size) if (animateAvatars) { diff --git a/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt index 2d1cfd0353..3c13b57cb6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt @@ -18,29 +18,22 @@ package com.keylesspalace.tusky import android.content.Context import android.util.Log import androidx.lifecycle.ViewModel -import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewModelScope -import androidx.room.PrimaryKey import at.connyduck.calladapter.networkresult.fold -import com.keylesspalace.tusky.MainActivity.Companion import com.keylesspalace.tusky.appstore.AnnouncementReadEvent import com.keylesspalace.tusky.appstore.ConversationsLoadingEvent import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.NewNotificationsEvent import com.keylesspalace.tusky.appstore.NotificationsLoadingEvent -import com.keylesspalace.tusky.appstore.ProfileEditedEvent import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper import com.keylesspalace.tusky.components.systemnotifications.disableAllNotifications import com.keylesspalace.tusky.components.systemnotifications.enablePushNotificationsWithFallback import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.di.ApplicationScope -import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.Resource import com.keylesspalace.tusky.util.ShareShortcutHelper import com.keylesspalace.tusky.util.deleteStaleCachedMedia import dagger.hilt.android.lifecycle.HiltViewModel @@ -49,12 +42,9 @@ import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map @@ -70,7 +60,7 @@ class MainViewModel @Inject constructor( private val eventHub: EventHub, private val accountManager: AccountManager, private val shareShortcutHelper: ShareShortcutHelper -): ViewModel() { +) : ViewModel() { private val activeAccount = accountManager.activeAccount @@ -107,7 +97,6 @@ class MainViewModel @Inject constructor( } .stateIn(viewModelScope, SharingStarted.Eagerly, false) - init { loadAccountData() fetchAnnouncements() @@ -137,7 +126,6 @@ class MainViewModel @Inject constructor( Log.w(TAG, "Failed to fetch user info.", throwable) } ) - } } @@ -163,16 +151,16 @@ class MainViewModel @Inject constructor( _unreadAnnouncementsCount.value-- } is NewNotificationsEvent -> { - if (event.accountId == activeAccount?.accountId) { - val hasDirectMessageNotification = - event.notifications.any { - it.type == Notification.Type.MENTION && it.status?.visibility == Status.Visibility.DIRECT - } - - if (hasDirectMessageNotification) { - accountManager.updateAccount(activeAccount) { copy(hasDirectMessageBadge = true) } + if (event.accountId == activeAccount?.accountId) { + val hasDirectMessageNotification = + event.notifications.any { + it.type == Notification.Type.MENTION && it.status?.visibility == Status.Visibility.DIRECT } + + if (hasDirectMessageNotification) { + accountManager.updateAccount(activeAccount) { copy(hasDirectMessageBadge = true) } } + } } is NotificationsLoadingEvent -> { if (event.accountId == activeAccount?.accountId) { diff --git a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt index 8ad017770f..b244eb1976 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt @@ -45,7 +45,6 @@ import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @AndroidEntryPoint @@ -335,7 +334,7 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener, ListSelec private fun saveTabs() { accountManager.activeAccount?.let { lifecycleScope.launch { - accountManager.updateAccount(it) { copy(tabPreferences = currentTabs)} + accountManager.updateAccount(it) { copy(tabPreferences = currentTabs) } } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt index 7534274848..7bc123437d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt @@ -1,6 +1,5 @@ package com.keylesspalace.tusky.appstore -import com.keylesspalace.tusky.TabData import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Poll diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt index c46c139c5c..0f22a3baf6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt @@ -140,7 +140,7 @@ class NotificationsRemoteMediator( notifications: List, state: PagingState, activeAccount: AccountEntity - ): Int { + ): Int { val overlappedNotifications = if (notifications.isNotEmpty()) { notificationsDao.deleteRange(activeAccount.id, notifications.last().id, notifications.first().id) } else { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt index 22f254ec36..0e776ca753 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt @@ -37,7 +37,6 @@ import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.domainblocks.DomainBlocksActivity import com.keylesspalace.tusky.components.filters.FiltersActivity import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity - import com.keylesspalace.tusky.components.preference.notificationpolicies.NotificationPoliciesActivity import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Account @@ -329,7 +328,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() { copy( defaultPostPrivacy = account.source?.privacy ?: Status.Visibility.PUBLIC, - defaultMediaSensitivity = account.source?.sensitive ?: false, + defaultMediaSensitivity = account.source?.sensitive == true, defaultPostLanguage = language.orEmpty() ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/PushNotificationHelper.kt b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/PushNotificationHelper.kt index 2100680d93..91ba6f7810 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/PushNotificationHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/PushNotificationHelper.kt @@ -21,14 +21,8 @@ import android.app.NotificationManager import android.content.Context import android.os.Build import android.util.Log -import android.view.View -import androidx.preference.PreferenceManager import at.connyduck.calladapter.networkresult.onFailure import at.connyduck.calladapter.networkresult.onSuccess -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.components.login.LoginActivity import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.entity.Notification diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt index 0ca0662394..bf075ff6d0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt @@ -24,25 +24,16 @@ import com.keylesspalace.tusky.di.ApplicationScope import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.settings.PrefKeys -import com.mikepenz.materialdrawer.model.SecondaryDrawerItem import java.util.Locale import javax.inject.Inject import javax.inject.Singleton import kotlin.reflect.KProperty import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking /** @@ -68,7 +59,6 @@ class AccountManager @Inject constructor( } .onCompletion { Log.d(TAG, "accounts flow completed: $it") - } .stateIn(CoroutineScope(applicationScope.coroutineContext + Dispatchers.IO)) } @@ -103,7 +93,7 @@ class AccountManager @Inject constructor( newAccount: Account ) = db.withTransaction { activeAccount?.let { - //it.isActive = false + // it.isActive = false Log.d(TAG, "addAccount: saving account with id " + it.id) accountDao.insertOrReplace(it.copy(isActive = false)) @@ -179,16 +169,16 @@ class AccountManager @Inject constructor( */ suspend fun updateAccount(accountEntity: AccountEntity, account: Account): AccountEntity { val newAccount = accountEntity.copy( - accountId = account.id, - username = account.username, - displayName = account.name, - profilePictureUrl = account.avatar, - profileHeaderUrl = account.header, - defaultPostPrivacy = account.source?.privacy ?: Status.Visibility.PUBLIC, - defaultPostLanguage = account.source?.language.orEmpty(), - defaultMediaSensitivity = account.source?.sensitive ?: false, - emojis = account.emojis, - locked = account.locked + accountId = account.id, + username = account.username, + displayName = account.name, + profilePictureUrl = account.avatar, + profileHeaderUrl = account.header, + defaultPostPrivacy = account.source?.privacy ?: Status.Visibility.PUBLIC, + defaultPostLanguage = account.source?.language.orEmpty(), + defaultMediaSensitivity = account.source?.sensitive ?: false, + emojis = account.emojis, + locked = account.locked ) Log.d(TAG, "updateAccount: saving account with id " + accountEntity.id) @@ -269,7 +259,6 @@ class AccountManager @Inject constructor( } } - class ActiveAccountDelegate( private val accountManager: AccountManager ) { From 70a6adee5e8b7424005f7b0e9c5c8ed5388cad85 Mon Sep 17 00:00:00 2001 From: Conny Duck Date: Wed, 4 Dec 2024 22:09:37 +0100 Subject: [PATCH 04/17] fix MainActivityTest --- .../com/keylesspalace/tusky/BaseActivity.kt | 1 - .../com/keylesspalace/tusky/MainActivity.kt | 12 +++++++ .../com/keylesspalace/tusky/MainViewModel.kt | 15 +------- .../tusky/worker/PruneCacheWorker.kt | 6 +++- .../tusky/BottomSheetActivityTest.kt | 1 - .../keylesspalace/tusky/MainActivityTest.kt | 36 +++++++++++++++---- .../keylesspalace/tusky/db/MigrationsTest.kt | 6 ++-- 7 files changed, 51 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.kt b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.kt index 70e9338597..5a05c53dff 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.kt @@ -168,7 +168,6 @@ abstract class BaseActivity : AppCompatActivity() { val currentAccounts = accountManager.accounts if (currentAccounts.isEmpty()) { - println("redirecting to Login") val intent = getIntent(this@BaseActivity, LoginActivity.MODE_DEFAULT) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) startActivity(intent) diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index f73287af50..f707b3a8ab 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -267,6 +267,13 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { lifecycleScope.launch { viewModel.tabs.collect(::setupTabs) } + if (showNotificationTab) { + val tabs = activeAccount.tabPreferences + val position = tabs.indexOfFirst { it.id == NOTIFICATIONS } + if (position != -1) { + binding.viewPager.setCurrentItem(position, false) + } + } lifecycleScope.launch { viewModel.showDirectMessagesBadge.collect { showBadge -> @@ -502,6 +509,10 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { finish() } + override fun finish() { + super.finish() + } + private fun setupDrawer( savedInstanceState: Bundle?, addSearchButton: Boolean, @@ -772,6 +783,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { } private fun setupTabs(tabs: List) { + println("setup tabs") val activeTabLayout = if (preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top") == "bottom") { val actionBarSize = getDimension(this, androidx.appcompat.R.attr.actionBarSize) val fabMargin = resources.getDimensionPixelSize(R.dimen.fabMargin) diff --git a/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt index 3c13b57cb6..69b0ecf1d7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt @@ -29,18 +29,14 @@ import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper import com.keylesspalace.tusky.components.systemnotifications.disableAllNotifications import com.keylesspalace.tusky.components.systemnotifications.enablePushNotificationsWithFallback import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.di.ApplicationScope import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.ShareShortcutHelper -import com.keylesspalace.tusky.util.deleteStaleCachedMedia import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -54,7 +50,6 @@ import kotlinx.coroutines.launch @HiltViewModel class MainViewModel @Inject constructor( - @ApplicationScope private val externalScope: CoroutineScope, @ApplicationContext private val context: Context, private val api: MastodonApi, private val eventHub: EventHub, @@ -101,7 +96,6 @@ class MainViewModel @Inject constructor( loadAccountData() fetchAnnouncements() collectEvents() - deleteStaleCachedMedia() } private fun loadAccountData() { @@ -112,7 +106,7 @@ class MainViewModel @Inject constructor( shareShortcutHelper.updateShortcuts() - NotificationHelper.createNotificationChannelsForAccount(activeAccount!!, context) + NotificationHelper.createNotificationChannelsForAccount(activeAccount, context) if (NotificationHelper.areNotificationsEnabled(context, accountManager)) { viewModelScope.launch { @@ -177,13 +171,6 @@ class MainViewModel @Inject constructor( } } - private fun deleteStaleCachedMedia() { - externalScope.launch(Dispatchers.IO) { - // Flush old media that was cached for sharing - deleteStaleCachedMedia(context.getExternalFilesDir("Tusky")) - } - } - fun dismissDirectMessagesBadge() { viewModelScope.launch { accountManager.updateAccount(activeAccount!!) { copy(hasDirectMessageBadge = false) } diff --git a/app/src/main/java/com/keylesspalace/tusky/worker/PruneCacheWorker.kt b/app/src/main/java/com/keylesspalace/tusky/worker/PruneCacheWorker.kt index b735ddca2d..8426630a3c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/worker/PruneCacheWorker.kt +++ b/app/src/main/java/com/keylesspalace/tusky/worker/PruneCacheWorker.kt @@ -29,13 +29,14 @@ import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper.NOTIFICATION_ID_PRUNE_CACHE import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.DatabaseCleaner +import com.keylesspalace.tusky.util.deleteStaleCachedMedia import dagger.assisted.Assisted import dagger.assisted.AssistedInject /** Prune the database cache of old statuses. */ @HiltWorker class PruneCacheWorker @AssistedInject constructor( - @Assisted appContext: Context, + @Assisted private val appContext: Context, @Assisted workerParams: WorkerParameters, private val databaseCleaner: DatabaseCleaner, private val accountManager: AccountManager @@ -50,6 +51,9 @@ class PruneCacheWorker @AssistedInject constructor( Log.d(TAG, "Pruning database using account ID: ${account.id}") databaseCleaner.cleanupOldData(account.id, MAX_HOMETIMELINE_ITEMS_IN_CACHE, MAX_NOTIFICATIONS_IN_CACHE) } + + deleteStaleCachedMedia(appContext.getExternalFilesDir("Tusky")) + return Result.success() } diff --git a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt index 395a310f55..c6fbbdfaf7 100644 --- a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt @@ -296,7 +296,6 @@ class BottomSheetActivityTest { init { mastodonApi = api - bottomSheet = mock() } override fun openLink(url: String) { diff --git a/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt index 94247c2f5f..4645c3506b 100644 --- a/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt @@ -3,7 +3,10 @@ package com.keylesspalace.tusky import android.app.Activity import android.app.NotificationManager import android.content.ComponentName +import android.content.Context import android.content.Intent +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import androidx.viewpager2.widget.ViewPager2 @@ -12,13 +15,15 @@ import at.connyduck.calladapter.networkresult.NetworkResult import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper +import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.TimelineAccount +import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.getSerializableExtraCompat import java.util.Date -import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.flow.MutableStateFlow import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull @@ -129,17 +134,20 @@ class MainActivityTest { private fun startMainActivity(intent: Intent): Activity { val controller = Robolectric.buildActivity(MainActivity::class.java, intent) val activity = controller.get() - activity.eventHub = EventHub() - activity.accountManager = mock { + val eventHub = EventHub() + activity.eventHub = eventHub + val accountManager: AccountManager = mock { + on { accounts } doReturn listOf(accountEntity) + on { accountsFlow } doReturn MutableStateFlow(listOf(accountEntity)) on { activeAccount } doReturn accountEntity } - activity.draftsAlert = mock {} - activity.shareShortcutHelper = mock {} - activity.externalScope = TestScope() - activity.mastodonApi = mock { + activity.accountManager = accountManager + activity.draftsAlert = mock { } + val api: MastodonApi = mock { onBlocking { accountVerifyCredentials() } doReturn NetworkResult.success(account) onBlocking { announcements() } doReturn NetworkResult.success(emptyList()) } + activity.mastodonApi = api activity.preferences = mock(defaultAnswer = { when (it.method.returnType) { String::class.java -> "test" @@ -147,6 +155,20 @@ class MainActivityTest { else -> null } }) + val viewModel = MainViewModel( + context = mock { + on { getSystemService(Context.NOTIFICATION_SERVICE) } doReturn mock() + }, + api = api, + eventHub = eventHub, + accountManager = accountManager, + shareShortcutHelper = mock() + ) + val testViewModelFactory = viewModelFactory { + initializer { viewModel } + } + activity.viewModelProviderFactory = testViewModelFactory + controller.create().start() return activity } diff --git a/app/src/test/java/com/keylesspalace/tusky/db/MigrationsTest.kt b/app/src/test/java/com/keylesspalace/tusky/db/MigrationsTest.kt index 4db567e7de..3253f68509 100644 --- a/app/src/test/java/com/keylesspalace/tusky/db/MigrationsTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/db/MigrationsTest.kt @@ -7,6 +7,8 @@ import com.keylesspalace.tusky.di.StorageModule import com.keylesspalace.tusky.entity.Emoji import com.squareup.moshi.Moshi import com.squareup.moshi.Types +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -24,7 +26,7 @@ class MigrationsTest { ) @Test - fun testMigrations() { + fun testMigrations() = runTest { /** the db name must match the one in [StorageModule.providesDatabase] */ val db = migrationHelper.createDatabase("tuskyDB", 10) val moshi = Moshi.Builder().build() @@ -73,7 +75,7 @@ class MigrationsTest { Converters(moshi) ) - val account = roomDb.accountDao().loadAll().first() + val account = roomDb.accountDao().allAccounts().first().first() roomDb.close() From f12a5be4e31c5b5deb87f3aafe77782b43650303 Mon Sep 17 00:00:00 2001 From: Conny Duck Date: Sat, 7 Dec 2024 20:37:09 +0100 Subject: [PATCH 05/17] revert BottomSheetActivity changes --- .../tusky/BottomSheetActivity.kt | 28 +++++++++++-------- .../tusky/BottomSheetActivityTest.kt | 1 + 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt index 2baeab0bfe..73c87cf2e8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt @@ -17,6 +17,7 @@ package com.keylesspalace.tusky import android.content.Context import android.content.Intent +import android.os.Bundle import android.view.View import android.widget.LinearLayout import android.widget.Toast @@ -40,22 +41,17 @@ import kotlinx.coroutines.launch abstract class BottomSheetActivity : BaseActivity() { + lateinit var bottomSheet: BottomSheetBehavior var searchUrl: String? = null @Inject lateinit var mastodonApi: MastodonApi - open fun viewUrl( - url: String, - lookupFallbackBehavior: PostLookupFallbackBehavior = PostLookupFallbackBehavior.OPEN_IN_BROWSER - ) { - if (!looksLikeMastodonUrl(url)) { - openLink(url) - return - } + override fun onPostCreate(savedInstanceState: Bundle?) { + super.onPostCreate(savedInstanceState) val bottomSheetLayout: LinearLayout = findViewById(R.id.item_status_bottom_sheet) - val bottomSheet = BottomSheetBehavior.from(bottomSheetLayout) + bottomSheet = BottomSheetBehavior.from(bottomSheetLayout) bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN bottomSheet.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { override fun onStateChanged(bottomSheet: View, newState: Int) { @@ -66,6 +62,16 @@ abstract class BottomSheetActivity : BaseActivity() { override fun onSlide(bottomSheet: View, slideOffset: Float) {} }) + } + + open fun viewUrl( + url: String, + lookupFallbackBehavior: PostLookupFallbackBehavior = PostLookupFallbackBehavior.OPEN_IN_BROWSER + ) { + if (!looksLikeMastodonUrl(url)) { + openLink(url) + return + } lifecycleScope.launch { mastodonApi.search( @@ -171,11 +177,11 @@ abstract class BottomSheetActivity : BaseActivity() { } private fun showQuerySheet() { - // bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED + bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED } private fun hideQuerySheet() { - // bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN + bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN } } diff --git a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt index c6fbbdfaf7..395a310f55 100644 --- a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt @@ -296,6 +296,7 @@ class BottomSheetActivityTest { init { mastodonApi = api + bottomSheet = mock() } override fun openLink(url: String) { From ee403d73aba71f3389089e63e6de51f80bf8798f Mon Sep 17 00:00:00 2001 From: Conny Duck Date: Sat, 7 Dec 2024 20:40:51 +0100 Subject: [PATCH 06/17] fix ComposeActivityTest --- .../tusky/components/compose/ComposeActivityTest.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivityTest.kt index 0f1fdce8cd..f2a08ca681 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivityTest.kt @@ -44,6 +44,7 @@ import com.squareup.moshi.adapter import java.util.Locale import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow import okhttp3.ResponseBody import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.Assert.assertEquals @@ -108,6 +109,8 @@ class ComposeActivityTest { activity = controller.get() accountManagerMock = mock { + on { accounts } doReturn listOf(account) + on { accountsFlow } doReturn MutableStateFlow(listOf(account)) on { activeAccount } doReturn account } From d8c8d967c9462387da53c3c1887c1dcb159bf0dc Mon Sep 17 00:00:00 2001 From: Conny Duck Date: Sat, 7 Dec 2024 20:41:20 +0100 Subject: [PATCH 07/17] fix code formatting --- .../test/java/com/keylesspalace/tusky/MainActivityTest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt index 4645c3506b..0de8ce851b 100644 --- a/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt @@ -160,9 +160,9 @@ class MainActivityTest { on { getSystemService(Context.NOTIFICATION_SERVICE) } doReturn mock() }, api = api, - eventHub = eventHub, - accountManager = accountManager, - shareShortcutHelper = mock() + eventHub = eventHub, + accountManager = accountManager, + shareShortcutHelper = mock() ) val testViewModelFactory = viewModelFactory { initializer { viewModel } From a6149492bca5fb6421d31be037f317ddd5be86ca Mon Sep 17 00:00:00 2001 From: Conny Duck Date: Sun, 8 Dec 2024 15:52:16 +0100 Subject: [PATCH 08/17] code cleanup --- .../com/keylesspalace/tusky/BaseActivity.kt | 4 ++-- .../NotificationFetcher.kt | 2 +- .../keylesspalace/tusky/db/AccountManager.kt | 19 ++----------------- 3 files changed, 5 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.kt b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.kt index 5a05c53dff..70b158e0a6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.kt @@ -180,7 +180,7 @@ abstract class BaseActivity : AppCompatActivity() { showActiveAccount: Boolean, listener: AccountSelectionListener ) { - val accounts = accountManager.getAllAccountsOrderedByActive().toMutableList() + val accounts = accountManager.accounts.toMutableList() val activeAccount = accountManager.activeAccount when (accounts.size) { @@ -217,7 +217,7 @@ abstract class BaseActivity : AppCompatActivity() { val openAsText: String? get() { - val accounts = accountManager.getAllAccountsOrderedByActive() + val accounts = accountManager.accounts when (accounts.size) { 0, 1 -> return null 2 -> { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationFetcher.kt b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationFetcher.kt index 9b8833eafd..ca880a6d51 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationFetcher.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationFetcher.kt @@ -53,7 +53,7 @@ class NotificationFetcher @Inject constructor( private val eventHub: EventHub ) { suspend fun fetchAndShow() { - for (account in accountManager.getAllAccountsOrderedByActive()) { + for (account in accountManager.accounts) { if (account.notificationsEnabled) { try { val notificationManager = context.getSystemService( diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt index bf075ff6d0..cf6a4073f7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt @@ -31,8 +31,6 @@ import kotlin.reflect.KProperty import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.runBlocking @@ -54,15 +52,10 @@ class AccountManager @Inject constructor( val accountsFlow: StateFlow> = runBlocking { accountDao.allAccounts() - .onEach { - Log.d(TAG, "accounts updated: $it") - } - .onCompletion { - Log.d(TAG, "accounts flow completed: $it") - } .stateIn(CoroutineScope(applicationScope.coroutineContext + Dispatchers.IO)) } + /** a list of all accounts in the database with the active account first */ val accounts: List get() = accountsFlow.value @@ -93,7 +86,6 @@ class AccountManager @Inject constructor( newAccount: Account ) = db.withTransaction { activeAccount?.let { - // it.isActive = false Log.d(TAG, "addAccount: saving account with id " + it.id) accountDao.insertOrReplace(it.copy(isActive = false)) @@ -111,7 +103,7 @@ class AccountManager @Inject constructor( isActive = true ) } else { - val maxAccountId = accounts.maxByOrNull { it.id }?.id ?: 0 + val maxAccountId = accounts.maxOfOrNull { it.id } ?: 0 val newAccountId = maxAccountId + 1 AccountEntity( id = newAccountId, @@ -204,13 +196,6 @@ class AccountManager @Inject constructor( accountDao.insertOrReplace(newActiveAccount.copy(isActive = true)) } - /** - * @return an immutable list of all accounts in the database with the active account first - */ - fun getAllAccountsOrderedByActive(): List { - return accounts - } - /** * @return true if at least one account has notifications enabled */ From a2ad8924349f59d899495dae0e314449ee41b099 Mon Sep 17 00:00:00 2001 From: Conny Duck Date: Sat, 14 Dec 2024 12:32:49 +0100 Subject: [PATCH 09/17] introduce an active account flow --- .../notifications/NotificationsFragment.kt | 2 +- .../NotificationsRemoteMediator.kt | 16 ++-- .../notifications/NotificationsViewModel.kt | 83 ++++++++++--------- .../viewmodel/CachedTimelineRemoteMediator.kt | 7 +- .../viewmodel/CachedTimelineViewModel.kt | 4 +- .../NetworkTimelineRemoteMediator.kt | 6 +- .../viewmodel/NetworkTimelineViewModel.kt | 2 +- .../timeline/viewmodel/TimelineViewModel.kt | 29 ++++--- .../keylesspalace/tusky/db/AccountManager.kt | 31 ++++--- 9 files changed, 98 insertions(+), 82 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt index 168b9cf566..c9f4ae8fb1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt @@ -314,7 +314,7 @@ class NotificationsFragment : override fun onRespondToFollowRequest(accept: Boolean, id: String, position: Int) { val notification = notificationsAdapter?.peek(position) ?: return - viewModel.respondToFollowRequest(accept, accountId = id, notificationId = notification.id) + viewModel.respondToFollowRequest(accept, id = id, notificationId = notification.id) } override fun onViewReport(reportId: String) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt index 0f22a3baf6..152662e088 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt @@ -36,10 +36,10 @@ import retrofit2.HttpException @OptIn(ExperimentalPagingApi::class) class NotificationsRemoteMediator( + private val viewModel: NotificationsViewModel, private val accountManager: AccountManager, private val api: MastodonApi, - private val db: AppDatabase, - var excludes: Set + private val db: AppDatabase ) : RemoteMediator() { private var initialRefresh = false @@ -47,16 +47,18 @@ class NotificationsRemoteMediator( private val notificationsDao = db.notificationsDao() private val accountDao = db.timelineAccountDao() private val statusDao = db.timelineStatusDao() - private val activeAccount = accountManager.activeAccount override suspend fun load( loadType: LoadType, state: PagingState ): MediatorResult { + val activeAccount = viewModel.activeAccountFlow.value if (activeAccount == null) { return MediatorResult.Success(endOfPaginationReached = true) } + val excludes = viewModel.excludes.value + try { var dbEmpty = false @@ -194,14 +196,12 @@ class NotificationsRemoteMediator( } private suspend fun saveNewestNotificationId(notification: Notification) { - val account = accountManager.activeAccount - // make sure the account we are currently working with is still active - if (account != null && account == activeAccount) { + viewModel.activeAccountFlow.value?.let { activeAccount -> val lastNotificationId: String = activeAccount.lastNotificationId val newestNotificationId = notification.id if (lastNotificationId.isLessThan(newestNotificationId)) { - Log.d(TAG, "saving newest noti id: $lastNotificationId for account ${account.id}") - accountManager.updateAccount(account) { copy(lastNotificationId = newestNotificationId) } + Log.d(TAG, "saving newest noti id: $lastNotificationId for account ${activeAccount.id}") + accountManager.updateAccount(activeAccount) { copy(lastNotificationId = newestNotificationId) } } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt index 9bb45a8ca1..935ce79208 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt @@ -48,6 +48,7 @@ import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.usecase.NotificationPolicyState import com.keylesspalace.tusky.usecase.NotificationPolicyUsecase import com.keylesspalace.tusky.usecase.TimelineCases +import com.keylesspalace.tusky.util.EmptyPagingSource import com.keylesspalace.tusky.util.deserialize import com.keylesspalace.tusky.util.serialize import com.keylesspalace.tusky.viewdata.NotificationViewData @@ -59,11 +60,13 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import retrofit2.HttpException @@ -79,19 +82,19 @@ class NotificationsViewModel @Inject constructor( private val notificationPolicyUsecase: NotificationPolicyUsecase ) : ViewModel() { + val activeAccountFlow = accountManager.activeAccount(viewModelScope) + private val accountId: Long = activeAccountFlow.value!!.id + private val refreshTrigger = MutableStateFlow(0L) - private val _excludes = MutableStateFlow( - accountManager.activeAccount?.let { account -> deserialize(account.notificationsFilter) } ?: emptySet() - ) - val excludes: StateFlow> = _excludes.asStateFlow() + val excludes: StateFlow> = activeAccountFlow + .map { account -> deserialize(account?.notificationsFilter ?: "[]" ) } + .stateIn(viewModelScope, SharingStarted.Eagerly, deserialize(activeAccountFlow.value?.notificationsFilter ?: "[]" )) /** Map from notification id to translation. */ private val translations = MutableStateFlow(mapOf()) - private val account = accountManager.activeAccount!! - - private var remoteMediator = NotificationsRemoteMediator(accountManager, api, db, excludes.value) + private var remoteMediator = NotificationsRemoteMediator(this, accountManager, api, db) private var readingOrder: ReadingOrder = ReadingOrder.from(preferences.getString(PrefKeys.READING_ORDER, null)) @@ -104,7 +107,8 @@ class NotificationsViewModel @Inject constructor( ), remoteMediator = remoteMediator, pagingSourceFactory = { - db.notificationsDao().getNotifications(account.id) + db.notificationsDao().getNotifications(accountId) + EmptyPagingSource() } ).flow .cachedIn(viewModelScope) @@ -149,15 +153,14 @@ class NotificationsViewModel @Inject constructor( } fun updateNotificationFilters(newFilters: Set) { - if (newFilters != _excludes.value) { + val account = activeAccountFlow.value + if (newFilters != excludes.value && account != null) { viewModelScope.launch { accountManager.updateAccount(account) { copy(notificationsFilter = serialize(newFilters)) } - remoteMediator.excludes = newFilters - db.notificationsDao().cleanupNotifications(account.id, 0) + db.notificationsDao().cleanupNotifications(accountId, 0) refreshTrigger.value++ - _excludes.value = newFilters } } } @@ -165,8 +168,9 @@ class NotificationsViewModel @Inject constructor( private fun shouldFilterStatus(notificationViewData: NotificationViewData): Filter.Action { return when ((notificationViewData as? NotificationViewData.Concrete)?.type) { Notification.Type.MENTION, Notification.Type.POLL -> { + val account = activeAccountFlow.value notificationViewData.statusViewData?.let { statusViewData -> - if (statusViewData.status.account.id == account.accountId) { + if (statusViewData.status.account.id == account?.accountId) { return Filter.Action.NONE } statusViewData.filterAction = filterModel.shouldFilterStatus(statusViewData.actionable) @@ -179,23 +183,23 @@ class NotificationsViewModel @Inject constructor( } } - fun respondToFollowRequest(accept: Boolean, accountId: String, notificationId: String) { + fun respondToFollowRequest(accept: Boolean, id: String, notificationId: String) { viewModelScope.launch { if (accept) { - api.authorizeFollowRequest(accountId) + api.authorizeFollowRequest(id) } else { - api.rejectFollowRequest(accountId) + api.rejectFollowRequest(id) }.fold( onSuccess = { // since the follow request has been responded, the notification can be deleted. The Ui will update automatically. - db.notificationsDao().delete(account.id, notificationId) + db.notificationsDao().delete(accountId, notificationId) if (accept) { // refresh the notifications so the new follow notification will be loaded refreshTrigger.value++ } }, onFailure = { t -> - Log.e(TAG, "Failed to to respond to follow request from account id $accountId.", t) + Log.e(TAG, "Failed to to respond to follow request from account id $id.", t) } ) } @@ -240,33 +244,33 @@ class NotificationsViewModel @Inject constructor( fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) { viewModelScope.launch { db.timelineStatusDao() - .setExpanded(account.id, status.id, expanded) + .setExpanded(accountId, status.id, expanded) } } fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) { viewModelScope.launch { db.timelineStatusDao() - .setContentShowing(account.id, status.id, isShowing) + .setContentShowing(accountId, status.id, isShowing) } } fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) { viewModelScope.launch { db.timelineStatusDao() - .setContentCollapsed(account.id, status.id, isCollapsed) + .setContentCollapsed(accountId, status.id, isCollapsed) } } fun remove(notificationId: String) { viewModelScope.launch { - db.notificationsDao().delete(account.id, notificationId) + db.notificationsDao().delete(accountId, notificationId) } } fun clearWarning(status: StatusViewData.Concrete) { viewModelScope.launch { - db.timelineStatusDao().clearWarning(account.id, status.actionableId) + db.timelineStatusDao().clearWarning(accountId, status.actionableId) } } @@ -274,7 +278,7 @@ class NotificationsViewModel @Inject constructor( viewModelScope.launch { api.clearNotifications().fold( { - db.notificationsDao().cleanupNotifications(account.id, 0) + db.notificationsDao().cleanupNotifications(accountId, 0) }, { t -> Log.w(TAG, "failed to clear notifications", t) @@ -305,13 +309,13 @@ class NotificationsViewModel @Inject constructor( notificationsDao.insertNotification( Placeholder(placeholderId, loading = true).toNotificationEntity( - account.id + accountId ) ) val response = db.withTransaction { - val idAbovePlaceholder = notificationsDao.getIdAbove(account.id, placeholderId) - val idBelowPlaceholder = notificationsDao.getIdBelow(account.id, placeholderId) + val idAbovePlaceholder = notificationsDao.getIdAbove(accountId, placeholderId) + val idBelowPlaceholder = notificationsDao.getIdBelow(accountId, placeholderId) when (readingOrder) { // Using minId, loads up to LOAD_AT_ONCE statuses with IDs immediately // after minId and no larger than maxId @@ -338,15 +342,20 @@ class NotificationsViewModel @Inject constructor( return@launch } + val account = activeAccountFlow.value + if (account == null) { + return@launch + } + val statusDao = db.timelineStatusDao() val accountDao = db.timelineAccountDao() db.withTransaction { - notificationsDao.delete(account.id, placeholderId) + notificationsDao.delete(accountId, placeholderId) val overlappedNotifications = if (notifications.isNotEmpty()) { notificationsDao.deleteRange( - account.id, + accountId, notifications.last().id, notifications.first().id ) @@ -355,18 +364,18 @@ class NotificationsViewModel @Inject constructor( } for (notification in notifications) { - accountDao.insert(notification.account.toEntity(account.id)) + accountDao.insert(notification.account.toEntity(accountId)) notification.report?.let { report -> - accountDao.insert(report.targetAccount.toEntity(account.id)) - notificationsDao.insertReport(report.toEntity(account.id)) + accountDao.insert(report.targetAccount.toEntity(accountId)) + notificationsDao.insertReport(report.toEntity(accountId)) } notification.status?.let { status -> val statusToInsert = status.reblog ?: status - accountDao.insert(statusToInsert.account.toEntity(account.id)) + accountDao.insert(statusToInsert.account.toEntity(accountId)) statusDao.insert( statusToInsert.toEntity( - tuskyAccountId = account.id, + tuskyAccountId = accountId, expanded = account.alwaysOpenSpoiler, contentShowing = account.alwaysShowSensitiveMedia || !status.sensitive, contentCollapsed = true @@ -375,7 +384,7 @@ class NotificationsViewModel @Inject constructor( } notificationsDao.insertNotification( notification.toEntity( - account.id + accountId ) ) } @@ -394,7 +403,7 @@ class NotificationsViewModel @Inject constructor( Placeholder( idToConvert, loading = false - ).toNotificationEntity(account.id) + ).toNotificationEntity(accountId) ) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt index 3c576236a6..13a47848f9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -24,7 +24,6 @@ import androidx.room.withTransaction import com.keylesspalace.tusky.components.timeline.Placeholder import com.keylesspalace.tusky.components.timeline.toEntity import com.keylesspalace.tusky.components.timeline.util.ifExpected -import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.db.entity.HomeTimelineData @@ -36,7 +35,7 @@ import retrofit2.HttpException @OptIn(ExperimentalPagingApi::class) class CachedTimelineRemoteMediator( - accountManager: AccountManager, + private val viewModel: CachedTimelineViewModel, private val api: MastodonApi, private val db: AppDatabase, ) : RemoteMediator() { @@ -46,12 +45,12 @@ class CachedTimelineRemoteMediator( private val timelineDao = db.timelineDao() private val statusDao = db.timelineStatusDao() private val accountDao = db.timelineAccountDao() - private val activeAccount = accountManager.activeAccount override suspend fun load( loadType: LoadType, state: PagingState ): MediatorResult { + val activeAccount = viewModel.activeAccountFlow.value if (activeAccount == null) { return MediatorResult.Success(endOfPaginationReached = true) } @@ -163,7 +162,7 @@ class CachedTimelineRemoteMediator( val expanded = oldStatus?.expanded ?: activeAccount.alwaysOpenSpoiler val contentShowing = oldStatus?.contentShowing ?: (activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive) - val contentCollapsed = oldStatus?.contentCollapsed ?: true + val contentCollapsed = oldStatus?.contentCollapsed != false statusDao.insert( status.actionableStatus.toEntity( diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index 036eb94f67..00423eec4d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -86,7 +86,7 @@ class CachedTimelineViewModel @Inject constructor( config = PagingConfig( pageSize = LOAD_AT_ONCE ), - remoteMediator = CachedTimelineRemoteMediator(accountManager, api, db), + remoteMediator = CachedTimelineRemoteMediator(this, api, db), pagingSourceFactory = { db.timelineDao().getHomeTimeline(account.id).also { newPagingSource -> this.currentPagingSource = newPagingSource @@ -252,7 +252,7 @@ class CachedTimelineViewModel @Inject constructor( } private suspend fun loadMoreFailed(placeholderId: String, e: Exception) { - Log.w("CachedTimelineVM", "failed loading statuses", e) + Log.w(TAG, "failed loading statuses", e) val activeAccount = accountManager.activeAccount!! db.timelineDao() .insertHomeTimelineItem(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id)) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt index f19b2240f8..566274afba 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt @@ -21,7 +21,6 @@ import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator import com.keylesspalace.tusky.components.timeline.util.ifExpected -import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.util.HttpHeaderLink import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData @@ -29,7 +28,6 @@ import retrofit2.HttpException @OptIn(ExperimentalPagingApi::class) class NetworkTimelineRemoteMediator( - private val accountManager: AccountManager, private val viewModel: NetworkTimelineViewModel ) : RemoteMediator() { @@ -68,7 +66,7 @@ class NetworkTimelineRemoteMediator( return MediatorResult.Error(HttpException(statusResponse)) } - val activeAccount = accountManager.activeAccount!! + val activeAccount = viewModel.activeAccountFlow.value!! val data = statuses.map { status -> @@ -78,7 +76,7 @@ class NetworkTimelineRemoteMediator( val contentShowing = oldStatus?.isShowingContent ?: (activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive) val expanded = oldStatus?.isExpanded ?: activeAccount.alwaysOpenSpoiler - val contentCollapsed = oldStatus?.isCollapsed ?: true + val contentCollapsed = oldStatus?.isCollapsed != false status.toViewData( isShowingContent = contentShowing, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index ff95bc2146..261d0c19a5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -96,7 +96,7 @@ class NetworkTimelineViewModel @Inject constructor( currentSource = source } }, - remoteMediator = NetworkTimelineRemoteMediator(accountManager, this) + remoteMediator = NetworkTimelineRemoteMediator(this) ).flow .map { pagingData -> pagingData.filter(Dispatchers.Default.asExecutor()) { statusViewData -> diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index ff915ea7b9..d3569a6584 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -28,6 +28,7 @@ import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.network.FilterModel @@ -46,7 +47,9 @@ abstract class TimelineViewModel( private val filterModel: FilterModel ) : ViewModel() { - protected val account = accountManager.activeAccount!! + val activeAccountFlow = accountManager.activeAccount(viewModelScope) + protected val account: AccountEntity + get() = activeAccountFlow.value!! abstract val statuses: Flow> @@ -69,19 +72,18 @@ abstract class TimelineViewModel( this.id = id this.tags = tags + val activeAccount = activeAccountFlow.value!! + if (kind == Kind.HOME) { // Note the variable is "true if filter" but the underlying preference/settings text is "true if show" - filterRemoveReplies = - !(accountManager.activeAccount?.isShowHomeReplies ?: true) - filterRemoveReblogs = - !(accountManager.activeAccount?.isShowHomeBoosts ?: true) - filterRemoveSelfReblogs = - !(accountManager.activeAccount?.isShowHomeSelfBoosts ?: true) + filterRemoveReplies = !activeAccount.isShowHomeReplies + filterRemoveReblogs = !activeAccount.isShowHomeBoosts + filterRemoveSelfReblogs = !activeAccount.isShowHomeSelfBoosts } readingOrder = ReadingOrder.from(sharedPreferences.getString(PrefKeys.READING_ORDER, null)) - this.alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia - this.alwaysOpenSpoilers = accountManager.activeAccount!!.alwaysOpenSpoiler + this.alwaysShowSensitiveMedia = activeAccount.alwaysShowSensitiveMedia + this.alwaysOpenSpoilers = activeAccount.alwaysOpenSpoiler viewModelScope.launch { eventHub.events @@ -181,7 +183,7 @@ abstract class TimelineViewModel( protected fun shouldFilterStatus(statusViewData: StatusViewData): Filter.Action { val status = statusViewData.asStatusOrNull()?.status ?: return Filter.Action.NONE - if (status.actionableStatus.account.id == account.accountId) { + if (status.actionableStatus.account.id == activeAccountFlow.value?.accountId) { // never filter own posts return Filter.Action.NONE } @@ -198,9 +200,10 @@ abstract class TimelineViewModel( } private fun onPreferenceChanged(key: String) { + val activeAccount = activeAccountFlow.value when (key) { PrefKeys.TAB_FILTER_HOME_REPLIES -> { - val filter = accountManager.activeAccount?.isShowHomeReplies ?: true + val filter = activeAccount?.isShowHomeReplies != false val oldRemoveReplies = filterRemoveReplies filterRemoveReplies = kind == Kind.HOME && !filter if (oldRemoveReplies != filterRemoveReplies) { @@ -208,7 +211,7 @@ abstract class TimelineViewModel( } } PrefKeys.TAB_FILTER_HOME_BOOSTS -> { - val filter = accountManager.activeAccount?.isShowHomeBoosts ?: true + val filter = activeAccount?.isShowHomeBoosts != false val oldRemoveReblogs = filterRemoveReblogs filterRemoveReblogs = kind == Kind.HOME && !filter if (oldRemoveReblogs != filterRemoveReblogs) { @@ -216,7 +219,7 @@ abstract class TimelineViewModel( } } PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS -> { - val filter = accountManager.activeAccount?.isShowHomeSelfBoosts ?: true + val filter = activeAccount?.isShowHomeSelfBoosts != false val oldRemoveSelfReblogs = filterRemoveSelfReblogs filterRemoveSelfReblogs = kind == Kind.HOME && !filter if (oldRemoveSelfReblogs != filterRemoveSelfReblogs) { diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt index cf6a4073f7..e303b059c7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt @@ -30,7 +30,15 @@ import javax.inject.Singleton import kotlin.reflect.KProperty import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.runBlocking @@ -59,6 +67,7 @@ class AccountManager @Inject constructor( val accounts: List get() = accountsFlow.value + /** the currently active account */ val activeAccount: AccountEntity? get() { val a = accounts.firstOrNull() @@ -66,7 +75,16 @@ class AccountManager @Inject constructor( return a } - fun activeAccount() = ActiveAccountDelegate(this) + /** Returns a StateFlow for updates to the currently active account. + * Note that the account will be null after it got logged out, + * and that always the same account will be returned, + * even if it is no longer active. */ + fun activeAccount(scope: CoroutineScope): StateFlow { + val activeAccount = activeAccount + return accountsFlow.map { accounts -> + accounts.find { account -> activeAccount?.id == account.id } + }.stateIn(scope, SharingStarted.Lazily, activeAccount) + } /** * Adds a new account and makes it the active account. @@ -243,14 +261,3 @@ class AccountManager @Inject constructor( return accounts.size > 1 // "disambiguate" } } - -class ActiveAccountDelegate( - private val accountManager: AccountManager -) { - - val accountId = accountManager.activeAccount?.id - - operator fun getValue(thisRef: Any?, property: KProperty<*>): AccountEntity? { - return accountManager.accounts.find { it.id == accountId } - } -} From 4a969d06cadd113a048c891b7aabc6e6a56770b2 Mon Sep 17 00:00:00 2001 From: Conny Duck Date: Sat, 21 Dec 2024 16:54:42 +0100 Subject: [PATCH 10/17] improve account handling in timelines --- .../viewmodel/CachedTimelineViewModel.kt | 37 +++++++++++-------- .../timeline/viewmodel/TimelineViewModel.kt | 4 +- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index 00423eec4d..9e26aaa507 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -88,7 +88,7 @@ class CachedTimelineViewModel @Inject constructor( ), remoteMediator = CachedTimelineRemoteMediator(this, api, db), pagingSourceFactory = { - db.timelineDao().getHomeTimeline(account.id).also { newPagingSource -> + db.timelineDao().getHomeTimeline(accountId).also { newPagingSource -> this.currentPagingSource = newPagingSource } } @@ -118,27 +118,27 @@ class CachedTimelineViewModel @Inject constructor( override fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) { viewModelScope.launch { db.timelineStatusDao() - .setExpanded(account.id, status.actionableId, expanded) + .setExpanded(accountId, status.actionableId, expanded) } } override fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) { viewModelScope.launch { db.timelineStatusDao() - .setContentShowing(account.id, status.actionableId, isShowing) + .setContentShowing(accountId, status.actionableId, isShowing) } } override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) { viewModelScope.launch { db.timelineStatusDao() - .setContentCollapsed(account.id, status.actionableId, isCollapsed) + .setContentCollapsed(accountId, status.actionableId, isCollapsed) } } override fun clearWarning(status: StatusViewData.Concrete) { viewModelScope.launch { - db.timelineStatusDao().clearWarning(account.id, status.actionableId) + db.timelineStatusDao().clearWarning(accountId, status.actionableId) } } @@ -154,12 +154,12 @@ class CachedTimelineViewModel @Inject constructor( val accountDao = db.timelineAccountDao() timelineDao.insertHomeTimelineItem( - Placeholder(placeholderId, loading = true).toEntity(tuskyAccountId = account.id) + Placeholder(placeholderId, loading = true).toEntity(tuskyAccountId = accountId) ) val response = db.withTransaction { - val idAbovePlaceholder = timelineDao.getIdAbove(account.id, placeholderId) - val idBelowPlaceholder = timelineDao.getIdBelow(account.id, placeholderId) + val idAbovePlaceholder = timelineDao.getIdAbove(accountId, placeholderId) + val idBelowPlaceholder = timelineDao.getIdBelow(accountId, placeholderId) when (readingOrder) { // Using minId, loads up to LOAD_AT_ONCE statuses with IDs immediately // after minId and no larger than maxId @@ -184,12 +184,17 @@ class CachedTimelineViewModel @Inject constructor( return@launch } + val account = activeAccountFlow.value + if (account == null) { + return@launch + } + db.withTransaction { - timelineDao.deleteHomeTimelineItem(account.id, placeholderId) + timelineDao.deleteHomeTimelineItem(accountId, placeholderId) val overlappedStatuses = if (statuses.isNotEmpty()) { timelineDao.deleteRange( - account.id, + accountId, statuses.last().id, statuses.first().id ) @@ -198,14 +203,14 @@ class CachedTimelineViewModel @Inject constructor( } for (status in statuses) { - accountDao.insert(status.account.toEntity(account.id)) - status.reblog?.account?.toEntity(account.id) + accountDao.insert(status.account.toEntity(accountId)) + status.reblog?.account?.toEntity(accountId) ?.let { rebloggedAccount -> accountDao.insert(rebloggedAccount) } statusDao.insert( status.actionableStatus.toEntity( - tuskyAccountId = account.id, + tuskyAccountId = accountId, expanded = account.alwaysOpenSpoiler, contentShowing = account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive, contentCollapsed = true @@ -213,7 +218,7 @@ class CachedTimelineViewModel @Inject constructor( ) timelineDao.insertHomeTimelineItem( HomeTimelineEntity( - tuskyAccountId = account.id, + tuskyAccountId = accountId, id = status.id, statusId = status.actionableId, reblogAccountId = if (status.reblog != null) { @@ -239,7 +244,7 @@ class CachedTimelineViewModel @Inject constructor( Placeholder( idToConvert, loading = false - ).toEntity(account.id) + ).toEntity(accountId) ) } } @@ -278,7 +283,7 @@ class CachedTimelineViewModel @Inject constructor( override suspend fun invalidate() { // invalidating when we don't have statuses yet can cause empty timelines because it cancels the network load - if (db.timelineDao().getHomeTimelineItemCount(account.id) > 0) { + if (db.timelineDao().getHomeTimelineItemCount(accountId) > 0) { currentPagingSource?.invalidate() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index d3569a6584..4030879f0c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -28,7 +28,6 @@ import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.network.FilterModel @@ -48,8 +47,7 @@ abstract class TimelineViewModel( ) : ViewModel() { val activeAccountFlow = accountManager.activeAccount(viewModelScope) - protected val account: AccountEntity - get() = activeAccountFlow.value!! + protected val accountId: Long = activeAccountFlow.value!!.id abstract val statuses: Flow> From 3912a16e5fdafc8506daf884ec03dd5dd2a38fe6 Mon Sep 17 00:00:00 2001 From: Conny Duck Date: Sat, 21 Dec 2024 21:19:39 +0100 Subject: [PATCH 11/17] fix code formatting --- .../components/notifications/NotificationsViewModel.kt | 4 ++-- .../main/java/com/keylesspalace/tusky/db/AccountManager.kt | 7 ------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt index 935ce79208..defea09ab1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt @@ -88,8 +88,8 @@ class NotificationsViewModel @Inject constructor( private val refreshTrigger = MutableStateFlow(0L) val excludes: StateFlow> = activeAccountFlow - .map { account -> deserialize(account?.notificationsFilter ?: "[]" ) } - .stateIn(viewModelScope, SharingStarted.Eagerly, deserialize(activeAccountFlow.value?.notificationsFilter ?: "[]" )) + .map { account -> deserialize(account?.notificationsFilter ?: "[]") } + .stateIn(viewModelScope, SharingStarted.Eagerly, deserialize(activeAccountFlow.value?.notificationsFilter ?: "[]")) /** Map from notification id to translation. */ private val translations = MutableStateFlow(mapOf()) diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt index e303b059c7..2703e14754 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt @@ -27,18 +27,11 @@ import com.keylesspalace.tusky.settings.PrefKeys import java.util.Locale import javax.inject.Inject import javax.inject.Singleton -import kotlin.reflect.KProperty import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.runBlocking From 853997764dc198e50620906f99679752419b8868 Mon Sep 17 00:00:00 2001 From: Conny Duck Date: Mon, 23 Dec 2024 20:05:59 +0100 Subject: [PATCH 12/17] fix notifications tab --- .../tusky/components/notifications/NotificationsViewModel.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt index defea09ab1..7ddd26a9e0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt @@ -108,7 +108,6 @@ class NotificationsViewModel @Inject constructor( remoteMediator = remoteMediator, pagingSourceFactory = { db.notificationsDao().getNotifications(accountId) - EmptyPagingSource() } ).flow .cachedIn(viewModelScope) From 5a5aa8f1ad03783876f9ed4670b4cbdf1a629ca8 Mon Sep 17 00:00:00 2001 From: Conny Duck Date: Sat, 28 Dec 2024 21:08:46 +0100 Subject: [PATCH 13/17] improve ConversationsViewModel --- .../ConversationsRemoteMediator.kt | 10 +++--- .../conversation/ConversationsViewModel.kt | 33 +++++++++---------- .../notifications/NotificationsViewModel.kt | 1 - .../tusky/util/EmptyPagingSource.kt | 14 -------- 4 files changed, 21 insertions(+), 37 deletions(-) delete mode 100644 app/src/main/java/com/keylesspalace/tusky/util/EmptyPagingSource.kt diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt index cf81e5ef62..fed00b3461 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt @@ -5,7 +5,6 @@ import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator import androidx.room.withTransaction -import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.HttpHeaderLink @@ -15,19 +14,22 @@ import retrofit2.HttpException class ConversationsRemoteMediator( private val api: MastodonApi, private val db: AppDatabase, - accountManager: AccountManager + private val viewModel: ConversationsViewModel ) : RemoteMediator() { private var nextKey: String? = null private var order: Int = 0 - private val activeAccount = accountManager.activeAccount!! - override suspend fun load( loadType: LoadType, state: PagingState ): MediatorResult { + val activeAccount = viewModel.activeAccountFlow.value + if (activeAccount == null) { + return MediatorResult.Success(endOfPaginationReached = true) + } + if (loadType == LoadType.PREPEND) { return MediatorResult.Success(endOfPaginationReached = true) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt index 27f63ae138..5e1fa6dbee 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt @@ -28,7 +28,6 @@ import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases -import com.keylesspalace.tusky.util.EmptyPagingSource import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.map @@ -38,23 +37,21 @@ import kotlinx.coroutines.launch class ConversationsViewModel @Inject constructor( private val timelineCases: TimelineCases, private val database: AppDatabase, - private val accountManager: AccountManager, - private val api: MastodonApi + private val api: MastodonApi, + accountManager: AccountManager ) : ViewModel() { + val activeAccountFlow = accountManager.activeAccount(viewModelScope) + private val accountId: Long = activeAccountFlow.value!!.id + @OptIn(ExperimentalPagingApi::class) val conversationFlow = Pager( config = PagingConfig( pageSize = 30 ), - remoteMediator = ConversationsRemoteMediator(api, database, accountManager), + remoteMediator = ConversationsRemoteMediator(api, database, this), pagingSourceFactory = { - val activeAccount = accountManager.activeAccount - if (activeAccount == null) { - EmptyPagingSource() - } else { - database.conversationDao().conversationsForAccount(activeAccount.id) - } + database.conversationDao().conversationsForAccount(accountId) } ) .flow @@ -67,7 +64,7 @@ class ConversationsViewModel @Inject constructor( viewModelScope.launch { timelineCases.favourite(conversation.lastStatus.id, favourite).fold({ val newConversation = conversation.toEntity( - accountId = accountManager.activeAccount!!.id, + accountId = accountId, favourited = favourite ) @@ -82,7 +79,7 @@ class ConversationsViewModel @Inject constructor( viewModelScope.launch { timelineCases.bookmark(conversation.lastStatus.id, bookmark).fold({ val newConversation = conversation.toEntity( - accountId = accountManager.activeAccount!!.id, + accountId = accountId, bookmarked = bookmark ) @@ -102,7 +99,7 @@ class ConversationsViewModel @Inject constructor( ) .fold({ poll -> val newConversation = conversation.toEntity( - accountId = accountManager.activeAccount!!.id, + accountId = accountId, poll = poll ) @@ -116,7 +113,7 @@ class ConversationsViewModel @Inject constructor( fun expandHiddenStatus(expanded: Boolean, conversation: ConversationViewData) { viewModelScope.launch { val newConversation = conversation.toEntity( - accountId = accountManager.activeAccount!!.id, + accountId = accountId, expanded = expanded ) saveConversationToDb(newConversation) @@ -126,7 +123,7 @@ class ConversationsViewModel @Inject constructor( fun collapseLongStatus(collapsed: Boolean, conversation: ConversationViewData) { viewModelScope.launch { val newConversation = conversation.toEntity( - accountId = accountManager.activeAccount!!.id, + accountId = accountId, collapsed = collapsed ) saveConversationToDb(newConversation) @@ -136,7 +133,7 @@ class ConversationsViewModel @Inject constructor( fun showContent(showing: Boolean, conversation: ConversationViewData) { viewModelScope.launch { val newConversation = conversation.toEntity( - accountId = accountManager.activeAccount!!.id, + accountId = accountId, showingHiddenContent = showing ) saveConversationToDb(newConversation) @@ -150,7 +147,7 @@ class ConversationsViewModel @Inject constructor( database.conversationDao().delete( id = conversation.id, - accountId = accountManager.activeAccount!!.id + accountId = accountId ) } catch (e: Exception) { Log.w(TAG, "failed to delete conversation", e) @@ -167,7 +164,7 @@ class ConversationsViewModel @Inject constructor( ) val newConversation = conversation.toEntity( - accountId = accountManager.activeAccount!!.id, + accountId = accountId, muted = !conversation.lastStatus.status.muted ) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt index 7ddd26a9e0..e181e4457d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt @@ -48,7 +48,6 @@ import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.usecase.NotificationPolicyState import com.keylesspalace.tusky.usecase.NotificationPolicyUsecase import com.keylesspalace.tusky.usecase.TimelineCases -import com.keylesspalace.tusky.util.EmptyPagingSource import com.keylesspalace.tusky.util.deserialize import com.keylesspalace.tusky.util.serialize import com.keylesspalace.tusky.viewdata.NotificationViewData diff --git a/app/src/main/java/com/keylesspalace/tusky/util/EmptyPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/util/EmptyPagingSource.kt deleted file mode 100644 index d08a9c1298..0000000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/EmptyPagingSource.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.keylesspalace.tusky.util - -import androidx.paging.PagingSource -import androidx.paging.PagingState - -class EmptyPagingSource : PagingSource() { - override fun getRefreshKey(state: PagingState): Int? = null - - override suspend fun load(params: LoadParams): LoadResult = LoadResult.Page( - emptyList(), - null, - null - ) -} From 57383d62f76ddafce3109053682510bcbd06c106 Mon Sep 17 00:00:00 2001 From: Conny Duck Date: Sat, 28 Dec 2024 21:37:01 +0100 Subject: [PATCH 14/17] fix tests --- .../timeline/viewmodel/TimelineViewModel.kt | 2 +- .../NotificationsRemoteMediatorTest.kt | 67 +++++++++++-------- .../CachedTimelineRemoteMediatorTest.kt | 52 ++++++++------ .../NetworkTimelineRemoteMediatorTest.kt | 54 ++++++++++----- 4 files changed, 108 insertions(+), 67 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index 4030879f0c..26fea13f9f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -41,7 +41,7 @@ import kotlinx.coroutines.launch abstract class TimelineViewModel( protected val timelineCases: TimelineCases, private val eventHub: EventHub, - protected val accountManager: AccountManager, + val accountManager: AccountManager, private val sharedPreferences: SharedPreferences, private val filterModel: FilterModel ) : ViewModel() { diff --git a/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediatorTest.kt index 2844d953a5..8fa82b31dc 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediatorTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediatorTest.kt @@ -20,6 +20,7 @@ import com.keylesspalace.tusky.db.entity.NotificationDataEntity import com.keylesspalace.tusky.di.NetworkModule import java.io.IOException import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.After @@ -42,15 +43,18 @@ import retrofit2.Response @RunWith(AndroidJUnit4::class) class NotificationsRemoteMediatorTest { + private val account = AccountEntity( + id = 1, + domain = "mastodon.example", + accessToken = "token", + clientId = "id", + clientSecret = "secret", + isActive = true + ) + private val accountManager: AccountManager = mock { - on { activeAccount } doReturn AccountEntity( - id = 1, - domain = "mastodon.example", - accessToken = "token", - clientId = "id", - clientSecret = "secret", - isActive = true - ) + on { activeAccount } doReturn account + on { accountsFlow } doReturn MutableStateFlow(listOf(account)) } private lateinit var db: AppDatabase @@ -78,12 +82,12 @@ class NotificationsRemoteMediatorTest { @ExperimentalPagingApi fun `should return error when network call returns error code`() = runTest { val remoteMediator = NotificationsRemoteMediator( + viewModel = mockViewModel(), accountManager = accountManager, api = mock { onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(500, "".toResponseBody()) }, - db = db, - excludes = emptySet() + db = db ) val result = remoteMediator.load(LoadType.REFRESH, state()) @@ -97,12 +101,12 @@ class NotificationsRemoteMediatorTest { @ExperimentalPagingApi fun `should return error when network call fails`() = runTest { val remoteMediator = NotificationsRemoteMediator( + viewModel = mockViewModel(), accountManager = accountManager, api = mock { onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doThrow IOException() }, - db = db, - excludes = emptySet() + db = db ) val result = remoteMediator.load(LoadType.REFRESH, state()) @@ -115,10 +119,10 @@ class NotificationsRemoteMediatorTest { @ExperimentalPagingApi fun `should not prepend notifications`() = runTest { val remoteMediator = NotificationsRemoteMediator( + viewModel = mockViewModel(), accountManager = accountManager, api = mock(), - db = db, - excludes = emptySet() + db = db ) val state = state( @@ -151,6 +155,7 @@ class NotificationsRemoteMediatorTest { db.insert(notificationsAlreadyInDb) val remoteMediator = NotificationsRemoteMediator( + viewModel = mockViewModel(), accountManager = accountManager, api = mock { onBlocking { notifications(limit = 3, excludes = emptySet()) } doReturn Response.success( @@ -168,8 +173,7 @@ class NotificationsRemoteMediatorTest { ) ) }, - db = db, - excludes = emptySet() + db = db ) val state = state( @@ -212,6 +216,7 @@ class NotificationsRemoteMediatorTest { db.insert(notificationsAlreadyInDb) val remoteMediator = NotificationsRemoteMediator( + viewModel = mockViewModel(), accountManager = accountManager, api = mock { onBlocking { notifications(limit = 20, excludes = emptySet()) } doReturn Response.success( @@ -239,8 +244,7 @@ class NotificationsRemoteMediatorTest { ) ) }, - db = db, - excludes = emptySet() + db = db ) val state = state( @@ -288,6 +292,7 @@ class NotificationsRemoteMediatorTest { db.insert(notificationsAlreadyInDb) val remoteMediator = NotificationsRemoteMediator( + viewModel = mockViewModel(), accountManager = accountManager, api = mock { onBlocking { notifications(limit = 3, excludes = emptySet()) } doReturn Response.success( @@ -305,8 +310,7 @@ class NotificationsRemoteMediatorTest { ) ) }, - db = db, - excludes = emptySet() + db = db ) val state = state( @@ -340,6 +344,7 @@ class NotificationsRemoteMediatorTest { @ExperimentalPagingApi fun `should not try to refresh already cached notifications when db is empty`() = runTest { val remoteMediator = NotificationsRemoteMediator( + viewModel = mockViewModel(), accountManager = accountManager, api = mock { onBlocking { notifications(limit = 20, excludes = emptySet()) } doReturn Response.success( @@ -350,8 +355,7 @@ class NotificationsRemoteMediatorTest { ) ) }, - db = db, - excludes = emptySet() + db = db ) val state = state( @@ -393,6 +397,7 @@ class NotificationsRemoteMediatorTest { db.timelineStatusDao().setContentCollapsed(1, "1", false) val remoteMediator = NotificationsRemoteMediator( + viewModel = mockViewModel(), accountManager = accountManager, api = mock { onBlocking { notifications(limit = 20, excludes = emptySet()) } doReturn Response.success(emptyList()) @@ -404,8 +409,7 @@ class NotificationsRemoteMediatorTest { ) ) }, - db = db, - excludes = emptySet() + db = db ) val state = state( @@ -449,6 +453,7 @@ class NotificationsRemoteMediatorTest { db.notificationsDao().insertNotification(placeholder) val remoteMediator = NotificationsRemoteMediator( + viewModel = mockViewModel(), accountManager = accountManager, api = mock { onBlocking { notifications(sinceId = "6", limit = 20, excludes = emptySet()) } doReturn Response.success( @@ -465,8 +470,7 @@ class NotificationsRemoteMediatorTest { ) ) }, - db = db, - excludes = emptySet() + db = db ) val state = state( @@ -507,6 +511,7 @@ class NotificationsRemoteMediatorTest { db.insert(notificationsAlreadyInDb) val remoteMediator = NotificationsRemoteMediator( + viewModel = mockViewModel(), accountManager = accountManager, api = mock { onBlocking { notifications(maxId = "5", limit = 20, excludes = emptySet()) } doReturn Response.success( @@ -517,8 +522,7 @@ class NotificationsRemoteMediatorTest { ) ) }, - db = db, - excludes = emptySet() + db = db ) val state = state( @@ -558,4 +562,11 @@ class NotificationsRemoteMediatorTest { ), leadingPlaceholderCount = 0 ) + + private fun mockViewModel(): NotificationsViewModel { + return mock { + on { activeAccountFlow } doReturn MutableStateFlow(account) + on { excludes } doReturn MutableStateFlow(emptySet()) + } + } } diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt index 10d704fb50..d5bbd5fd76 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt @@ -11,6 +11,7 @@ import androidx.room.Room import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineRemoteMediator +import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.Converters @@ -19,6 +20,7 @@ import com.keylesspalace.tusky.db.entity.HomeTimelineData import com.keylesspalace.tusky.di.NetworkModule import java.io.IOException import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.After @@ -41,17 +43,6 @@ import retrofit2.Response @RunWith(AndroidJUnit4::class) class CachedTimelineRemoteMediatorTest { - private val accountManager: AccountManager = mock { - on { activeAccount } doReturn AccountEntity( - id = 1, - domain = "mastodon.example", - accessToken = "token", - clientId = "id", - clientSecret = "secret", - isActive = true - ) - } - private lateinit var db: AppDatabase private val moshi = NetworkModule.providesMoshi() @@ -77,7 +68,7 @@ class CachedTimelineRemoteMediatorTest { @ExperimentalPagingApi fun `should return error when network call returns error code`() = runTest { val remoteMediator = CachedTimelineRemoteMediator( - accountManager = accountManager, + viewModel = mockViewModel(), api = mock { onBlocking { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(500, "".toResponseBody()) }, @@ -95,7 +86,7 @@ class CachedTimelineRemoteMediatorTest { @ExperimentalPagingApi fun `should return error when network call fails`() = runTest { val remoteMediator = CachedTimelineRemoteMediator( - accountManager = accountManager, + viewModel = mockViewModel(), api = mock { onBlocking { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doThrow IOException() }, @@ -112,7 +103,7 @@ class CachedTimelineRemoteMediatorTest { @ExperimentalPagingApi fun `should not prepend statuses`() = runTest { val remoteMediator = CachedTimelineRemoteMediator( - accountManager = accountManager, + viewModel = mockViewModel(), api = mock(), db = db, ) @@ -147,7 +138,7 @@ class CachedTimelineRemoteMediatorTest { db.insert(statusesAlreadyInDb) val remoteMediator = CachedTimelineRemoteMediator( - accountManager = accountManager, + viewModel = mockViewModel(), api = mock { onBlocking { homeTimeline(limit = 3) } doReturn Response.success( listOf( @@ -207,7 +198,7 @@ class CachedTimelineRemoteMediatorTest { db.insert(statusesAlreadyInDb) val remoteMediator = CachedTimelineRemoteMediator( - accountManager = accountManager, + viewModel = mockViewModel(), api = mock { onBlocking { homeTimeline(limit = 20) } doReturn Response.success( listOf( @@ -266,7 +257,7 @@ class CachedTimelineRemoteMediatorTest { db.insert(statusesAlreadyInDb) val remoteMediator = CachedTimelineRemoteMediator( - accountManager = accountManager, + viewModel = mockViewModel(), api = mock { onBlocking { homeTimeline(limit = 3) } doReturn Response.success( listOf( @@ -317,7 +308,7 @@ class CachedTimelineRemoteMediatorTest { @ExperimentalPagingApi fun `should not try to refresh already cached statuses when db is empty`() = runTest { val remoteMediator = CachedTimelineRemoteMediator( - accountManager = accountManager, + viewModel = mockViewModel(), api = mock { onBlocking { homeTimeline(limit = 20) } doReturn Response.success( listOf( @@ -366,7 +357,7 @@ class CachedTimelineRemoteMediatorTest { db.insert(statusesAlreadyInDb) val remoteMediator = CachedTimelineRemoteMediator( - accountManager = accountManager, + viewModel = mockViewModel(), api = mock { onBlocking { homeTimeline(limit = 20) } doReturn Response.success(emptyList()) @@ -416,7 +407,7 @@ class CachedTimelineRemoteMediatorTest { db.insert(statusesAlreadyInDb) val remoteMediator = CachedTimelineRemoteMediator( - accountManager = accountManager, + viewModel = mockViewModel(), api = mock { onBlocking { homeTimeline(sinceId = "6", limit = 20) } doReturn Response.success( listOf( @@ -473,7 +464,7 @@ class CachedTimelineRemoteMediatorTest { db.insert(statusesAlreadyInDb) val remoteMediator = CachedTimelineRemoteMediator( - accountManager = accountManager, + viewModel = mockViewModel(), api = mock { onBlocking { homeTimeline(maxId = "5", limit = 20) } doReturn Response.success( listOf( @@ -523,4 +514,23 @@ class CachedTimelineRemoteMediatorTest { ), leadingPlaceholderCount = 0 ) + + private fun mockViewModel(): CachedTimelineViewModel { + val account = AccountEntity( + id = 1, + domain = "mastodon.example", + accessToken = "token", + clientId = "id", + clientSecret = "secret", + isActive = true + ) + val accManager: AccountManager = mock { + on { activeAccount } doReturn account + on { accountsFlow } doReturn MutableStateFlow(listOf(account)) + } + return mock { + on { accountManager } doReturn accManager + on { activeAccountFlow } doReturn MutableStateFlow(account) + } + } } diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt index 84529d62f4..459a9deb7e 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt @@ -14,6 +14,7 @@ import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.viewdata.StatusViewData import java.io.IOException +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import okhttp3.Headers import okhttp3.ResponseBody.Companion.toResponseBody @@ -34,15 +35,17 @@ import retrofit2.Response @RunWith(AndroidJUnit4::class) class NetworkTimelineRemoteMediatorTest { + private val account = AccountEntity( + id = 1, + domain = "mastodon.example", + accessToken = "token", + clientId = "id", + clientSecret = "secret", + isActive = true + ) + private val accountManager: AccountManager = mock { - on { activeAccount } doReturn AccountEntity( - id = 1, - domain = "mastodon.example", - accessToken = "token", - clientId = "id", - clientSecret = "secret", - isActive = true - ) + on { activeAccount } doReturn account } @Test @@ -53,7 +56,7 @@ class NetworkTimelineRemoteMediatorTest { onBlocking { fetchStatusesForKind(anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(500, "".toResponseBody()) } - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + val remoteMediator = NetworkTimelineRemoteMediator(timelineViewModel) val result = remoteMediator.load(LoadType.REFRESH, state()) @@ -66,11 +69,13 @@ class NetworkTimelineRemoteMediatorTest { @ExperimentalPagingApi fun `should return error when network call fails`() = runTest { val timelineViewModel: NetworkTimelineViewModel = mock { + on { accountManager } doReturn accountManager + on { activeAccountFlow } doReturn MutableStateFlow(account) on { statusData } doReturn mutableListOf() onBlocking { fetchStatusesForKind(anyOrNull(), anyOrNull(), anyOrNull()) } doThrow IOException() } - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + val remoteMediator = NetworkTimelineRemoteMediator(timelineViewModel) val result = remoteMediator.load(LoadType.REFRESH, state()) @@ -84,6 +89,9 @@ class NetworkTimelineRemoteMediatorTest { val statuses: MutableList = mutableListOf() val timelineViewModel: NetworkTimelineViewModel = mock { + on { accountManager } doReturn accountManager + on { activeAccountFlow } doReturn MutableStateFlow(account) + on { activeAccountFlow } doReturn MutableStateFlow(account) on { statusData } doReturn statuses on { nextKey } doReturn null onBlocking { fetchStatusesForKind(null, null, 20) } doReturn Response.success( @@ -99,7 +107,7 @@ class NetworkTimelineRemoteMediatorTest { ) } - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + val remoteMediator = NetworkTimelineRemoteMediator(timelineViewModel) val state = state( listOf( @@ -135,6 +143,8 @@ class NetworkTimelineRemoteMediatorTest { ) val timelineViewModel: NetworkTimelineViewModel = mock { + on { accountManager } doReturn accountManager + on { activeAccountFlow } doReturn MutableStateFlow(account) on { statusData } doReturn statuses on { nextKey } doReturn "0" onBlocking { fetchStatusesForKind(null, null, 20) } doReturn Response.success( @@ -146,7 +156,7 @@ class NetworkTimelineRemoteMediatorTest { ) } - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + val remoteMediator = NetworkTimelineRemoteMediator(timelineViewModel) val state = state( listOf( @@ -187,6 +197,8 @@ class NetworkTimelineRemoteMediatorTest { ) val timelineViewModel: NetworkTimelineViewModel = mock { + on { accountManager } doReturn accountManager + on { activeAccountFlow } doReturn MutableStateFlow(account) on { statusData } doReturn statuses on { nextKey } doReturn "0" onBlocking { fetchStatusesForKind(null, null, 20) } doReturn Response.success( @@ -198,7 +210,7 @@ class NetworkTimelineRemoteMediatorTest { ) } - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + val remoteMediator = NetworkTimelineRemoteMediator(timelineViewModel) val state = state( listOf( @@ -240,6 +252,8 @@ class NetworkTimelineRemoteMediatorTest { ) val timelineViewModel: NetworkTimelineViewModel = mock { + on { accountManager } doReturn accountManager + on { activeAccountFlow } doReturn MutableStateFlow(account) on { statusData } doReturn statuses on { nextKey } doReturn "3" onBlocking { fetchStatusesForKind("3", null, 20) } doReturn Response.success( @@ -251,7 +265,7 @@ class NetworkTimelineRemoteMediatorTest { ) } - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + val remoteMediator = NetworkTimelineRemoteMediator(timelineViewModel) val state = state( listOf( @@ -293,6 +307,8 @@ class NetworkTimelineRemoteMediatorTest { ) val timelineViewModel: NetworkTimelineViewModel = mock { + on { accountManager } doReturn accountManager + on { activeAccountFlow } doReturn MutableStateFlow(account) on { statusData } doReturn statuses on { nextKey } doReturn "3" onBlocking { fetchStatusesForKind("3", null, 20) } doReturn Response.success( @@ -308,7 +324,7 @@ class NetworkTimelineRemoteMediatorTest { ) } - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + val remoteMediator = NetworkTimelineRemoteMediator(timelineViewModel) val state = state( listOf( @@ -350,11 +366,13 @@ class NetworkTimelineRemoteMediatorTest { ) val timelineViewModel: NetworkTimelineViewModel = mock { + on { accountManager } doReturn accountManager + on { activeAccountFlow } doReturn MutableStateFlow(account) on { statusData } doReturn statuses on { nextKey } doReturn null } - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + val remoteMediator = NetworkTimelineRemoteMediator(timelineViewModel) val state = state( listOf( @@ -393,6 +411,8 @@ class NetworkTimelineRemoteMediatorTest { ) val timelineViewModel: NetworkTimelineViewModel = mock { + on { accountManager } doReturn accountManager + on { activeAccountFlow } doReturn MutableStateFlow(account) on { statusData } doReturn statuses on { nextKey } doReturn "3" on { kind } doReturn TimelineViewModel.Kind.PUBLIC_TRENDING_STATUSES @@ -409,7 +429,7 @@ class NetworkTimelineRemoteMediatorTest { ) } - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + val remoteMediator = NetworkTimelineRemoteMediator(timelineViewModel) val state = state( listOf( From 273da1d78f63f79ff86c571da44e7aa55975052e Mon Sep 17 00:00:00 2001 From: Conny Duck Date: Mon, 6 Jan 2025 11:15:37 +0100 Subject: [PATCH 15/17] improve AccountManager code --- .../keylesspalace/tusky/db/AccountManager.kt | 94 +++++++++---------- .../tusky/usecase/LogoutUsecase.kt | 2 +- 2 files changed, 45 insertions(+), 51 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt index 2703e14754..6edb13763d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt @@ -36,8 +36,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.runBlocking /** - * This class caches the account database and handles all account related operations - * @author ConnyDuck + * This class is the main interface to all account related operations. */ private const val TAG = "AccountManager" @@ -51,27 +50,27 @@ class AccountManager @Inject constructor( private val accountDao: AccountDao = db.accountDao() + /** A StateFlow that will update everytime an account in the database changes, is added or removed. + * The first account is the currently active one. + */ val accountsFlow: StateFlow> = runBlocking { accountDao.allAccounts() .stateIn(CoroutineScope(applicationScope.coroutineContext + Dispatchers.IO)) } - /** a list of all accounts in the database with the active account first */ + /** A list of all accounts in the database with the active account first */ val accounts: List get() = accountsFlow.value - /** the currently active account */ + /** The currently active account, if there is one */ val activeAccount: AccountEntity? - get() { - val a = accounts.firstOrNull() - Log.d(TAG, "returning active account with id ${a?.id}") - return a - } + get() = accounts.firstOrNull() /** Returns a StateFlow for updates to the currently active account. - * Note that the account will be null after it got logged out, - * and that always the same account will be returned, - * even if it is no longer active. */ + * Note that always the same account will be emitted, + * even if it is no longer active and that it will emit null when the account got removed. + * @param scope the [CoroutineScope] this flow will be active in. + */ fun activeAccount(scope: CoroutineScope): StateFlow { val activeAccount = activeAccount return accountsFlow.map { accounts -> @@ -133,64 +132,59 @@ class AccountManager @Inject constructor( /** * Saves an already known account to the database. * New accounts must be created with [addAccount] - * @param account the account to save + * @param account The account to save + * @param changer make the changes to save here - this is to make sure no stale data gets re-saved to the database */ suspend fun updateAccount(account: AccountEntity, changer: AccountEntity.() -> AccountEntity) { - if (account.id != 0L) { - // get the newest version of the account to make sure no stale data gets re-saved to db - val acc = accounts.find { it.id == account.id } ?: return + accounts.find { it.id == account.id }?.let { acc -> + Log.d(TAG, "updateAccount: saving account with id " + acc.id) accountDao.insertOrReplace(changer(acc)) } } /** - * Logs an account out by deleting all its data. + * Updates an account with new information from the Mastodon api + * and saves it in the database. + * @param accountEntity the [AccountEntity] to update + * @param account the [Account] object which the newest data from the api + */ + suspend fun updateAccount(accountEntity: AccountEntity, account: Account) { + updateAccount (accountEntity) { + copy( + accountId = account.id, + username = account.username, + displayName = account.name, + profilePictureUrl = account.avatar, + profileHeaderUrl = account.header, + defaultPostPrivacy = account.source?.privacy ?: Status.Visibility.PUBLIC, + defaultPostLanguage = account.source?.language.orEmpty(), + defaultMediaSensitivity = account.source?.sensitive == true, + emojis = account.emojis, + locked = account.locked + ) + } + } + + /** + * Removes an account from the database. * @return the new active account, or null if no other account was found */ - suspend fun logout(account: AccountEntity): AccountEntity? = db.withTransaction { + suspend fun remove(account: AccountEntity): AccountEntity? = db.withTransaction { + Log.d(TAG, "remove: deleting account with id " + account.id) accountDao.delete(account) - val otherAccount = accounts.find { it.id != account.id } - if (otherAccount != null) { + accounts.find { it.id != account.id }?.let{ otherAccount -> val otherAccountActive = otherAccount.copy( isActive = true ) - Log.d(TAG, "logActiveAccountOut: saving account with id " + otherAccountActive.id) + Log.d(TAG, "remove: saving account with id " + otherAccountActive.id) accountDao.insertOrReplace(otherAccountActive) otherAccountActive - } else { - null } } /** - * Updates an account with new information from the Mastodon api - * and saves it in the database. - * @param accountEntity the [AccountEntity] to update - * @param account the [Account] object which the newest data from the api - * @return the updated [AccountEntity] - */ - suspend fun updateAccount(accountEntity: AccountEntity, account: Account): AccountEntity { - val newAccount = accountEntity.copy( - accountId = account.id, - username = account.username, - displayName = account.name, - profilePictureUrl = account.avatar, - profileHeaderUrl = account.header, - defaultPostPrivacy = account.source?.privacy ?: Status.Visibility.PUBLIC, - defaultPostLanguage = account.source?.language.orEmpty(), - defaultMediaSensitivity = account.source?.sensitive ?: false, - emojis = account.emojis, - locked = account.locked - ) - - Log.d(TAG, "updateAccount: saving account with id " + accountEntity.id) - accountDao.insertOrReplace(newAccount) - return newAccount - } - - /** - * changes the active account + * Changes the active account * @param accountId the database id of the new active account */ suspend fun setActiveAccount(accountId: Long) = db.withTransaction { diff --git a/app/src/main/java/com/keylesspalace/tusky/usecase/LogoutUsecase.kt b/app/src/main/java/com/keylesspalace/tusky/usecase/LogoutUsecase.kt index 55ac3fdebf..d3cf8159b7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/usecase/LogoutUsecase.kt +++ b/app/src/main/java/com/keylesspalace/tusky/usecase/LogoutUsecase.kt @@ -50,7 +50,7 @@ class LogoutUsecase @Inject constructor( NotificationHelper.deleteNotificationChannelsForAccount(account, context) // remove account from local AccountManager - val otherAccountAvailable = accountManager.logout(account) != null + val otherAccountAvailable = accountManager.remove(account) != null // clear the database - this could trigger network calls so do it last when all tokens are gone databaseCleaner.cleanupEverything(account.id) From a5cb918c29eae9d0119cbc08366c27f091471488 Mon Sep 17 00:00:00 2001 From: Conny Duck Date: Mon, 6 Jan 2025 11:34:22 +0100 Subject: [PATCH 16/17] improve AccountManager and MainViewModel --- .../com/keylesspalace/tusky/MainViewModel.kt | 20 ++++----- .../keylesspalace/tusky/db/AccountManager.kt | 43 ++++++++++--------- 2 files changed, 31 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt index 69b0ecf1d7..df2650d7bb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt @@ -57,16 +57,14 @@ class MainViewModel @Inject constructor( private val shareShortcutHelper: ShareShortcutHelper ) : ViewModel() { - private val activeAccount = accountManager.activeAccount + private val activeAccount = accountManager.activeAccount!! val accounts: StateFlow> = accountManager.accountsFlow .map { accounts -> accounts.map { account -> AccountViewData( id = account.id, - isActive = account.isActive, domain = account.domain, - accountId = account.accountId, username = account.username, displayName = account.displayName, profilePictureUrl = account.profilePictureUrl, @@ -79,7 +77,7 @@ class MainViewModel @Inject constructor( val tabs: Flow> = accountManager.accountsFlow .mapNotNull { accounts -> - accounts.find { activeAccount?.id == it.id }?.tabPreferences + accounts.find { activeAccount.id == it.id }?.tabPreferences } .distinctUntilChanged() @@ -88,7 +86,7 @@ class MainViewModel @Inject constructor( val showDirectMessagesBadge: StateFlow = accountManager.accountsFlow .map { accounts -> - accounts.find { activeAccount?.id == it.id }?.hasDirectMessageBadge == true + accounts.find { activeAccount.id == it.id }?.hasDirectMessageBadge == true } .stateIn(viewModelScope, SharingStarted.Eagerly, false) @@ -102,7 +100,7 @@ class MainViewModel @Inject constructor( viewModelScope.launch { api.accountVerifyCredentials().fold( { userInfo -> - accountManager.updateAccount(activeAccount!!, userInfo) + accountManager.updateAccount(activeAccount, userInfo) shareShortcutHelper.updateShortcuts() @@ -145,7 +143,7 @@ class MainViewModel @Inject constructor( _unreadAnnouncementsCount.value-- } is NewNotificationsEvent -> { - if (event.accountId == activeAccount?.accountId) { + if (event.accountId == activeAccount.accountId) { val hasDirectMessageNotification = event.notifications.any { it.type == Notification.Type.MENTION && it.status?.visibility == Status.Visibility.DIRECT @@ -157,12 +155,12 @@ class MainViewModel @Inject constructor( } } is NotificationsLoadingEvent -> { - if (event.accountId == activeAccount?.accountId) { + if (event.accountId == activeAccount.accountId) { accountManager.updateAccount(activeAccount) { copy(hasDirectMessageBadge = false) } } } is ConversationsLoadingEvent -> { - if (event.accountId == activeAccount?.accountId) { + if (event.accountId == activeAccount.accountId) { accountManager.updateAccount(activeAccount) { copy(hasDirectMessageBadge = false) } } } @@ -173,7 +171,7 @@ class MainViewModel @Inject constructor( fun dismissDirectMessagesBadge() { viewModelScope.launch { - accountManager.updateAccount(activeAccount!!) { copy(hasDirectMessageBadge = false) } + accountManager.updateAccount(activeAccount) { copy(hasDirectMessageBadge = false) } } } @@ -184,9 +182,7 @@ class MainViewModel @Inject constructor( data class AccountViewData( val id: Long, - val isActive: Boolean, val domain: String, - val accountId: String, val username: String, val displayName: String, val profilePictureUrl: String, diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt index 6edb13763d..86d3ddc3e1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt @@ -58,11 +58,11 @@ class AccountManager @Inject constructor( .stateIn(CoroutineScope(applicationScope.coroutineContext + Dispatchers.IO)) } - /** A list of all accounts in the database with the active account first */ + /** A snapshot of all accounts in the database with the active account first */ val accounts: List get() = accountsFlow.value - /** The currently active account, if there is one */ + /** A snapshot currently active account, if there is one */ val activeAccount: AccountEntity? get() = accounts.firstOrNull() @@ -97,15 +97,14 @@ class AccountManager @Inject constructor( ) = db.withTransaction { activeAccount?.let { Log.d(TAG, "addAccount: saving account with id " + it.id) - accountDao.insertOrReplace(it.copy(isActive = false)) } // check if this is a relogin with an existing account, if yes update it, otherwise create a new one - val existingAccountIndex = accounts.indexOfFirst { account -> + val existingAccount = accounts.find { account -> domain == account.domain && newAccount.id == account.accountId } - val newAccountEntity = if (existingAccountIndex != -1) { - accounts[existingAccountIndex].copy( + val newAccountEntity = if (existingAccount != null) { + existingAccount.copy( accessToken = accessToken, clientId = clientId, clientSecret = clientSecret, @@ -149,20 +148,24 @@ class AccountManager @Inject constructor( * @param account the [Account] object which the newest data from the api */ suspend fun updateAccount(accountEntity: AccountEntity, account: Account) { - updateAccount (accountEntity) { - copy( - accountId = account.id, - username = account.username, - displayName = account.name, - profilePictureUrl = account.avatar, - profileHeaderUrl = account.header, - defaultPostPrivacy = account.source?.privacy ?: Status.Visibility.PUBLIC, - defaultPostLanguage = account.source?.language.orEmpty(), - defaultMediaSensitivity = account.source?.sensitive == true, - emojis = account.emojis, - locked = account.locked - ) - } + // make sure no stale data gets re-saved to the database + val accountToUpdate = accounts.find { it.id == accountEntity.id } ?: accountEntity + + val newAccount = accountToUpdate.copy( + accountId = account.id, + username = account.username, + displayName = account.name, + profilePictureUrl = account.avatar, + profileHeaderUrl = account.header, + defaultPostPrivacy = account.source?.privacy ?: Status.Visibility.PUBLIC, + defaultPostLanguage = account.source?.language.orEmpty(), + defaultMediaSensitivity = account.source?.sensitive == true, + emojis = account.emojis, + locked = account.locked + ) + + Log.d(TAG, "updateAccount: saving account with id " + accountToUpdate.id) + accountDao.insertOrReplace(newAccount) } /** From 6f11cbadf3ea81b527ab45d51bc5d0c1c8a6728d Mon Sep 17 00:00:00 2001 From: Conny Duck Date: Mon, 6 Jan 2025 11:38:19 +0100 Subject: [PATCH 17/17] fix code formatting --- app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt index 86d3ddc3e1..00459255dd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt @@ -176,7 +176,7 @@ class AccountManager @Inject constructor( Log.d(TAG, "remove: deleting account with id " + account.id) accountDao.delete(account) - accounts.find { it.id != account.id }?.let{ otherAccount -> + accounts.find { it.id != account.id }?.let { otherAccount -> val otherAccountActive = otherAccount.copy( isActive = true )