From d41f40f7d38c4531345d90c09d71df1306baae40 Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Sun, 28 Sep 2025 13:43:24 -0500 Subject: [PATCH] Add FeedWithGroups object to handle multi-folder feeds Introduces a one-to-many relationship between feeds and groups so that a feed can belong to many groups. This enables the app to handle services like Feedbin which allow a feed to be tagged in many groups/folders. --- .../10.json | 488 +++++++++++++++++ .../8.json | 510 ++++++++++++++++++ .../9.json | 488 +++++++++++++++++ .../me/ash/reader/domain/model/feed/Feed.kt | 14 +- .../reader/domain/model/feed/FeedWithGroup.kt | 15 - .../domain/model/feed/FeedWithGroups.kt | 27 + .../domain/model/feedgroup/FeedGroup.kt | 47 ++ .../domain/model/group/GroupWithFeed.kt | 17 +- .../model/group/GroupWithFeedViaJunction.kt | 28 + .../reader/domain/repository/FeedGroupDao.kt | 235 ++++++++ .../domain/service/AbstractRssRepository.kt | 19 + .../reader/domain/service/AccountService.kt | 9 +- .../reader/domain/service/FeverRssService.kt | 3 + .../domain/service/GoogleReaderRssService.kt | 177 +++--- .../reader/domain/service/LocalRssService.kt | 3 + .../infrastructure/db/AndroidDatabase.kt | 30 +- .../infrastructure/di/AccountServiceModule.kt | 5 +- .../infrastructure/di/DatabaseModule.kt | 7 + .../adaptive/ArticleListReaderViewModel.kt | 31 +- 19 files changed, 2017 insertions(+), 136 deletions(-) create mode 100644 app/schemas/me.ash.reader.infrastructure.db.AndroidDatabase/10.json create mode 100644 app/schemas/me.ash.reader.infrastructure.db.AndroidDatabase/8.json create mode 100644 app/schemas/me.ash.reader.infrastructure.db.AndroidDatabase/9.json delete mode 100644 app/src/main/java/me/ash/reader/domain/model/feed/FeedWithGroup.kt create mode 100644 app/src/main/java/me/ash/reader/domain/model/feed/FeedWithGroups.kt create mode 100644 app/src/main/java/me/ash/reader/domain/model/feedgroup/FeedGroup.kt create mode 100644 app/src/main/java/me/ash/reader/domain/model/group/GroupWithFeedViaJunction.kt create mode 100644 app/src/main/java/me/ash/reader/domain/repository/FeedGroupDao.kt diff --git a/app/schemas/me.ash.reader.infrastructure.db.AndroidDatabase/10.json b/app/schemas/me.ash.reader.infrastructure.db.AndroidDatabase/10.json new file mode 100644 index 000000000..e816d6b4e --- /dev/null +++ b/app/schemas/me.ash.reader.infrastructure.db.AndroidDatabase/10.json @@ -0,0 +1,488 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "7242b5cbf3dc5672dc36c66caf0ef6f4", + "entities": [ + { + "tableName": "account", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `updateAt` INTEGER, `lastArticleId` TEXT, `syncInterval` INTEGER NOT NULL DEFAULT 30, `syncOnStart` INTEGER NOT NULL DEFAULT 0, `syncOnlyOnWiFi` INTEGER NOT NULL DEFAULT 0, `syncOnlyWhenCharging` INTEGER NOT NULL DEFAULT 0, `keepArchived` INTEGER NOT NULL DEFAULT 2592000000, `syncBlockList` TEXT NOT NULL DEFAULT '', `securityKey` TEXT DEFAULT 'CvJ1PKM8EW8=')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updateAt", + "columnName": "updateAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastArticleId", + "columnName": "lastArticleId", + "affinity": "TEXT" + }, + { + "fieldPath": "syncInterval", + "columnName": "syncInterval", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "30" + }, + { + "fieldPath": "syncOnStart", + "columnName": "syncOnStart", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "syncOnlyOnWiFi", + "columnName": "syncOnlyOnWiFi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "syncOnlyWhenCharging", + "columnName": "syncOnlyWhenCharging", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "keepArchived", + "columnName": "keepArchived", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "2592000000" + }, + { + "fieldPath": "syncBlockList", + "columnName": "syncBlockList", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "securityKey", + "columnName": "securityKey", + "affinity": "TEXT", + "defaultValue": "'CvJ1PKM8EW8='" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "feed", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT, `url` TEXT NOT NULL, `groupId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `isNotification` INTEGER NOT NULL, `isFullContent` INTEGER NOT NULL, `isBrowser` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotification", + "columnName": "isNotification", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFullContent", + "columnName": "isFullContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isBrowser", + "columnName": "isBrowser", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_feed_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ] + }, + { + "tableName": "article", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `date` INTEGER NOT NULL, `title` TEXT NOT NULL, `author` TEXT, `rawDescription` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `fullContent` TEXT, `img` TEXT, `link` TEXT NOT NULL, `feedId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `isUnread` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `isReadLater` INTEGER NOT NULL, `updateAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`feedId`) REFERENCES `feed`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT" + }, + { + "fieldPath": "rawDescription", + "columnName": "rawDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fullContent", + "columnName": "fullContent", + "affinity": "TEXT" + }, + { + "fieldPath": "img", + "columnName": "img", + "affinity": "TEXT" + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "feedId", + "columnName": "feedId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnread", + "columnName": "isUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isStarred", + "columnName": "isStarred", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReadLater", + "columnName": "isReadLater", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updateAt", + "columnName": "updateAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_article_feedId", + "unique": false, + "columnNames": [ + "feedId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_article_feedId` ON `${TABLE_NAME}` (`feedId`)" + }, + { + "name": "index_article_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_article_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "feed", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "feedId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "group", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `accountId` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_group_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_group_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ] + }, + { + "tableName": "archived_article", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `feedId` TEXT NOT NULL, `link` TEXT NOT NULL, FOREIGN KEY(`feedId`) REFERENCES `feed`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "feedId", + "columnName": "feedId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "feed", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "feedId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "feed_group", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`feedId` TEXT NOT NULL, `groupId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, PRIMARY KEY(`feedId`, `groupId`, `accountId`), FOREIGN KEY(`feedId`) REFERENCES `feed`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`groupId`) REFERENCES `group`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`accountId`) REFERENCES `account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "feedId", + "columnName": "feedId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "feedId", + "groupId", + "accountId" + ] + }, + "indices": [ + { + "name": "index_feed_group_feedId", + "unique": false, + "columnNames": [ + "feedId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_feedId` ON `${TABLE_NAME}` (`feedId`)" + }, + { + "name": "index_feed_group_groupId", + "unique": false, + "columnNames": [ + "groupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_groupId` ON `${TABLE_NAME}` (`groupId`)" + }, + { + "name": "index_feed_group_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "feed", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "feedId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "group", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "groupId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "account", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7242b5cbf3dc5672dc36c66caf0ef6f4')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/me.ash.reader.infrastructure.db.AndroidDatabase/8.json b/app/schemas/me.ash.reader.infrastructure.db.AndroidDatabase/8.json new file mode 100644 index 000000000..1b3e4c7ae --- /dev/null +++ b/app/schemas/me.ash.reader.infrastructure.db.AndroidDatabase/8.json @@ -0,0 +1,510 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "df124c0bf3a2cd73dc82e4cb97a693ac", + "entities": [ + { + "tableName": "account", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `updateAt` INTEGER, `lastArticleId` TEXT, `syncInterval` INTEGER NOT NULL DEFAULT 30, `syncOnStart` INTEGER NOT NULL DEFAULT 0, `syncOnlyOnWiFi` INTEGER NOT NULL DEFAULT 0, `syncOnlyWhenCharging` INTEGER NOT NULL DEFAULT 0, `keepArchived` INTEGER NOT NULL DEFAULT 2592000000, `syncBlockList` TEXT NOT NULL DEFAULT '', `securityKey` TEXT DEFAULT 'CvJ1PKM8EW8=')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updateAt", + "columnName": "updateAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastArticleId", + "columnName": "lastArticleId", + "affinity": "TEXT" + }, + { + "fieldPath": "syncInterval", + "columnName": "syncInterval", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "30" + }, + { + "fieldPath": "syncOnStart", + "columnName": "syncOnStart", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "syncOnlyOnWiFi", + "columnName": "syncOnlyOnWiFi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "syncOnlyWhenCharging", + "columnName": "syncOnlyWhenCharging", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "keepArchived", + "columnName": "keepArchived", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "2592000000" + }, + { + "fieldPath": "syncBlockList", + "columnName": "syncBlockList", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "securityKey", + "columnName": "securityKey", + "affinity": "TEXT", + "defaultValue": "'CvJ1PKM8EW8='" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "feed", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT, `url` TEXT NOT NULL, `groupId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `isNotification` INTEGER NOT NULL, `isFullContent` INTEGER NOT NULL, `isBrowser` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`), FOREIGN KEY(`groupId`) REFERENCES `group`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotification", + "columnName": "isNotification", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFullContent", + "columnName": "isFullContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isBrowser", + "columnName": "isBrowser", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_feed_groupId", + "unique": false, + "columnNames": [ + "groupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_groupId` ON `${TABLE_NAME}` (`groupId`)" + }, + { + "name": "index_feed_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "group", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "groupId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "article", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `date` INTEGER NOT NULL, `title` TEXT NOT NULL, `author` TEXT, `rawDescription` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `fullContent` TEXT, `img` TEXT, `link` TEXT NOT NULL, `feedId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `isUnread` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `isReadLater` INTEGER NOT NULL, `updateAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`feedId`) REFERENCES `feed`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT" + }, + { + "fieldPath": "rawDescription", + "columnName": "rawDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fullContent", + "columnName": "fullContent", + "affinity": "TEXT" + }, + { + "fieldPath": "img", + "columnName": "img", + "affinity": "TEXT" + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "feedId", + "columnName": "feedId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnread", + "columnName": "isUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isStarred", + "columnName": "isStarred", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReadLater", + "columnName": "isReadLater", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updateAt", + "columnName": "updateAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_article_feedId", + "unique": false, + "columnNames": [ + "feedId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_article_feedId` ON `${TABLE_NAME}` (`feedId`)" + }, + { + "name": "index_article_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_article_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "feed", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "feedId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "group", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `accountId` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_group_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_group_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ] + }, + { + "tableName": "archived_article", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `feedId` TEXT NOT NULL, `link` TEXT NOT NULL, FOREIGN KEY(`feedId`) REFERENCES `feed`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "feedId", + "columnName": "feedId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "feed", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "feedId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "feed_group", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`feedId` TEXT NOT NULL, `groupId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, PRIMARY KEY(`feedId`, `groupId`, `accountId`), FOREIGN KEY(`feedId`) REFERENCES `feed`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`groupId`) REFERENCES `group`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`accountId`) REFERENCES `account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "feedId", + "columnName": "feedId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "feedId", + "groupId", + "accountId" + ] + }, + "indices": [ + { + "name": "index_feed_group_feedId", + "unique": false, + "columnNames": [ + "feedId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_feedId` ON `${TABLE_NAME}` (`feedId`)" + }, + { + "name": "index_feed_group_groupId", + "unique": false, + "columnNames": [ + "groupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_groupId` ON `${TABLE_NAME}` (`groupId`)" + }, + { + "name": "index_feed_group_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "feed", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "feedId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "group", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "groupId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "account", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'df124c0bf3a2cd73dc82e4cb97a693ac')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/me.ash.reader.infrastructure.db.AndroidDatabase/9.json b/app/schemas/me.ash.reader.infrastructure.db.AndroidDatabase/9.json new file mode 100644 index 000000000..c13b2fee3 --- /dev/null +++ b/app/schemas/me.ash.reader.infrastructure.db.AndroidDatabase/9.json @@ -0,0 +1,488 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "7242b5cbf3dc5672dc36c66caf0ef6f4", + "entities": [ + { + "tableName": "account", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `updateAt` INTEGER, `lastArticleId` TEXT, `syncInterval` INTEGER NOT NULL DEFAULT 30, `syncOnStart` INTEGER NOT NULL DEFAULT 0, `syncOnlyOnWiFi` INTEGER NOT NULL DEFAULT 0, `syncOnlyWhenCharging` INTEGER NOT NULL DEFAULT 0, `keepArchived` INTEGER NOT NULL DEFAULT 2592000000, `syncBlockList` TEXT NOT NULL DEFAULT '', `securityKey` TEXT DEFAULT 'CvJ1PKM8EW8=')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updateAt", + "columnName": "updateAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastArticleId", + "columnName": "lastArticleId", + "affinity": "TEXT" + }, + { + "fieldPath": "syncInterval", + "columnName": "syncInterval", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "30" + }, + { + "fieldPath": "syncOnStart", + "columnName": "syncOnStart", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "syncOnlyOnWiFi", + "columnName": "syncOnlyOnWiFi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "syncOnlyWhenCharging", + "columnName": "syncOnlyWhenCharging", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "keepArchived", + "columnName": "keepArchived", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "2592000000" + }, + { + "fieldPath": "syncBlockList", + "columnName": "syncBlockList", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "securityKey", + "columnName": "securityKey", + "affinity": "TEXT", + "defaultValue": "'CvJ1PKM8EW8='" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "feed", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT, `url` TEXT NOT NULL, `groupId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `isNotification` INTEGER NOT NULL, `isFullContent` INTEGER NOT NULL, `isBrowser` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotification", + "columnName": "isNotification", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFullContent", + "columnName": "isFullContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isBrowser", + "columnName": "isBrowser", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_feed_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ] + }, + { + "tableName": "article", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `date` INTEGER NOT NULL, `title` TEXT NOT NULL, `author` TEXT, `rawDescription` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `fullContent` TEXT, `img` TEXT, `link` TEXT NOT NULL, `feedId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `isUnread` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `isReadLater` INTEGER NOT NULL, `updateAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`feedId`) REFERENCES `feed`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT" + }, + { + "fieldPath": "rawDescription", + "columnName": "rawDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fullContent", + "columnName": "fullContent", + "affinity": "TEXT" + }, + { + "fieldPath": "img", + "columnName": "img", + "affinity": "TEXT" + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "feedId", + "columnName": "feedId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnread", + "columnName": "isUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isStarred", + "columnName": "isStarred", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReadLater", + "columnName": "isReadLater", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updateAt", + "columnName": "updateAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_article_feedId", + "unique": false, + "columnNames": [ + "feedId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_article_feedId` ON `${TABLE_NAME}` (`feedId`)" + }, + { + "name": "index_article_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_article_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "feed", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "feedId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "group", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `accountId` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_group_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_group_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ] + }, + { + "tableName": "archived_article", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `feedId` TEXT NOT NULL, `link` TEXT NOT NULL, FOREIGN KEY(`feedId`) REFERENCES `feed`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "feedId", + "columnName": "feedId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "feed", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "feedId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "feed_group", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`feedId` TEXT NOT NULL, `groupId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, PRIMARY KEY(`feedId`, `groupId`, `accountId`), FOREIGN KEY(`feedId`) REFERENCES `feed`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`groupId`) REFERENCES `group`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`accountId`) REFERENCES `account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "feedId", + "columnName": "feedId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "feedId", + "groupId", + "accountId" + ] + }, + "indices": [ + { + "name": "index_feed_group_feedId", + "unique": false, + "columnNames": [ + "feedId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_feedId` ON `${TABLE_NAME}` (`feedId`)" + }, + { + "name": "index_feed_group_groupId", + "unique": false, + "columnNames": [ + "groupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_groupId` ON `${TABLE_NAME}` (`groupId`)" + }, + { + "name": "index_feed_group_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "feed", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "feedId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "group", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "groupId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "account", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7242b5cbf3dc5672dc36c66caf0ef6f4')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/domain/model/feed/Feed.kt b/app/src/main/java/me/ash/reader/domain/model/feed/Feed.kt index 7405e3313..e78e4b3af 100644 --- a/app/src/main/java/me/ash/reader/domain/model/feed/Feed.kt +++ b/app/src/main/java/me/ash/reader/domain/model/feed/Feed.kt @@ -1,20 +1,9 @@ package me.ash.reader.domain.model.feed import androidx.room.* -import me.ash.reader.domain.model.group.Group -/** - * TODO: Add class description - */ @Entity( tableName = "feed", - foreignKeys = [ForeignKey( - entity = Group::class, - parentColumns = ["id"], - childColumns = ["groupId"], - onDelete = ForeignKey.CASCADE, - onUpdate = ForeignKey.CASCADE, - )], ) data class Feed( @PrimaryKey @@ -25,8 +14,7 @@ data class Feed( val icon: String? = null, @ColumnInfo val url: String, - @ColumnInfo(index = true) - var groupId: String, + var groupId: String = "", @ColumnInfo(index = true) val accountId: Int, @ColumnInfo diff --git a/app/src/main/java/me/ash/reader/domain/model/feed/FeedWithGroup.kt b/app/src/main/java/me/ash/reader/domain/model/feed/FeedWithGroup.kt deleted file mode 100644 index 8d7ddbe6b..000000000 --- a/app/src/main/java/me/ash/reader/domain/model/feed/FeedWithGroup.kt +++ /dev/null @@ -1,15 +0,0 @@ -package me.ash.reader.domain.model.feed - -import androidx.room.Embedded -import androidx.room.Relation -import me.ash.reader.domain.model.group.Group - -/** - * A [feed] contains a [group]. - */ -data class FeedWithGroup( - @Embedded - var feed: Feed, - @Relation(parentColumn = "groupId", entityColumn = "id") - var group: Group, -) diff --git a/app/src/main/java/me/ash/reader/domain/model/feed/FeedWithGroups.kt b/app/src/main/java/me/ash/reader/domain/model/feed/FeedWithGroups.kt new file mode 100644 index 000000000..dc874e554 --- /dev/null +++ b/app/src/main/java/me/ash/reader/domain/model/feed/FeedWithGroups.kt @@ -0,0 +1,27 @@ +package me.ash.reader.domain.model.feed + +import androidx.room.Embedded +import androidx.room.Junction +import androidx.room.Relation +import me.ash.reader.domain.model.feedgroup.FeedGroup +import me.ash.reader.domain.model.group.Group + +/** + * A [feed] belongs to many [groups] via many-to-many relationship through [FeedGroup] junction table. + * + * This supports the use case where a single feed can be organized into multiple groups/folders. + */ +data class FeedWithGroups( + @Embedded + val feed: Feed, + @Relation( + parentColumn = "id", + entityColumn = "id", + associateBy = Junction( + value = FeedGroup::class, + parentColumn = "feedId", + entityColumn = "groupId" + ) + ) + val groups: List, +) diff --git a/app/src/main/java/me/ash/reader/domain/model/feedgroup/FeedGroup.kt b/app/src/main/java/me/ash/reader/domain/model/feedgroup/FeedGroup.kt new file mode 100644 index 000000000..5e80b3f69 --- /dev/null +++ b/app/src/main/java/me/ash/reader/domain/model/feedgroup/FeedGroup.kt @@ -0,0 +1,47 @@ +package me.ash.reader.domain.model.feedgroup + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import me.ash.reader.domain.model.account.Account +import me.ash.reader.domain.model.feed.Feed +import me.ash.reader.domain.model.group.Group + +/** + * Junction table to handle feeds that belong to one or more groups + */ +@Entity( + tableName = "feed_group", + primaryKeys = ["feedId", "groupId", "accountId"], + foreignKeys = [ + ForeignKey( + entity = Feed::class, + parentColumns = ["id"], + childColumns = ["feedId"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = Group::class, + parentColumns = ["id"], + childColumns = ["groupId"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = Account::class, + parentColumns = ["id"], + childColumns = ["accountId"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [ + Index(value = ["feedId"]), + Index(value = ["groupId"]), + Index(value = ["accountId"]) + ] +) +data class FeedGroup( + val feedId: String, + val groupId: String, + val accountId: Int, +) diff --git a/app/src/main/java/me/ash/reader/domain/model/group/GroupWithFeed.kt b/app/src/main/java/me/ash/reader/domain/model/group/GroupWithFeed.kt index 839eb5c44..a4d2f10b0 100644 --- a/app/src/main/java/me/ash/reader/domain/model/group/GroupWithFeed.kt +++ b/app/src/main/java/me/ash/reader/domain/model/group/GroupWithFeed.kt @@ -1,15 +1,28 @@ package me.ash.reader.domain.model.group import androidx.room.Embedded +import androidx.room.Junction import androidx.room.Relation import me.ash.reader.domain.model.feed.Feed +import me.ash.reader.domain.model.feedgroup.FeedGroup /** - * A [group] contains many [feeds]. + * A [group] contains many [feeds] via the [FeedGroup] junction table. + * + * This represents a many-to-many relationship where feeds can belong to multiple groups. + * The junction table ([FeedGroup]) is the source of truth for feed-group associations. */ data class GroupWithFeed( @Embedded val group: Group, - @Relation(parentColumn = "id", entityColumn = "groupId") + @Relation( + parentColumn = "id", + entityColumn = "id", + associateBy = Junction( + value = FeedGroup::class, + parentColumn = "groupId", + entityColumn = "feedId" + ) + ) val feeds: MutableList, ) diff --git a/app/src/main/java/me/ash/reader/domain/model/group/GroupWithFeedViaJunction.kt b/app/src/main/java/me/ash/reader/domain/model/group/GroupWithFeedViaJunction.kt new file mode 100644 index 000000000..eb9b97d54 --- /dev/null +++ b/app/src/main/java/me/ash/reader/domain/model/group/GroupWithFeedViaJunction.kt @@ -0,0 +1,28 @@ +package me.ash.reader.domain.model.group + +import androidx.room.Embedded +import androidx.room.Junction +import androidx.room.Relation +import me.ash.reader.domain.model.feed.Feed +import me.ash.reader.domain.model.feedgroup.FeedGroup + +/** + * A [group] contains many [feeds] via many-to-many relationship through [FeedGroup] junction table. + * + * This differs from [GroupWithFeed] which uses the legacy one-to-many relationship via Feed.groupId. + * Use this class for queries that need to support feeds belonging to multiple groups. + */ +data class GroupWithFeedViaJunction( + @Embedded + val group: Group, + @Relation( + parentColumn = "id", + entityColumn = "id", + associateBy = Junction( + value = FeedGroup::class, + parentColumn = "groupId", + entityColumn = "feedId" + ) + ) + val feeds: List, +) diff --git a/app/src/main/java/me/ash/reader/domain/repository/FeedGroupDao.kt b/app/src/main/java/me/ash/reader/domain/repository/FeedGroupDao.kt new file mode 100644 index 000000000..436e79941 --- /dev/null +++ b/app/src/main/java/me/ash/reader/domain/repository/FeedGroupDao.kt @@ -0,0 +1,235 @@ +package me.ash.reader.domain.repository + +import androidx.room.* +import kotlinx.coroutines.flow.Flow +import me.ash.reader.domain.model.feed.Feed +import me.ash.reader.domain.model.feedgroup.FeedGroup +import me.ash.reader.domain.model.group.Group + +@Dao +interface FeedGroupDao { + + /** + * Insert a feed-group association. + * If the association already exists, it will be replaced. + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(vararg feedGroup: FeedGroup) + + /** + * Insert multiple feed-group associations. + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertList(feedGroups: List) + + /** + * Delete a specific feed-group association. + */ + @Delete + suspend fun delete(vararg feedGroup: FeedGroup) + + /** + * Delete a specific feed-group association by IDs. + */ + @Query( + """ + DELETE FROM feed_group + WHERE feedId = :feedId + AND groupId = :groupId + AND accountId = :accountId + """ + ) + suspend fun deleteByIds(feedId: String, groupId: String, accountId: Int) + + /** + * Delete all associations for a specific feed. + * Useful when removing a feed from all groups. + */ + @Query( + """ + DELETE FROM feed_group + WHERE feedId = :feedId + AND accountId = :accountId + """ + ) + suspend fun deleteByFeedId(feedId: String, accountId: Int) + + /** + * Delete all associations for a specific group. + * Called when a group is deleted to clean up associations. + */ + @Query( + """ + DELETE FROM feed_group + WHERE groupId = :groupId + AND accountId = :accountId + """ + ) + suspend fun deleteByGroupId(groupId: String, accountId: Int) + + /** + * Delete all associations for an account. + * Called when an account is deleted. + */ + @Query( + """ + DELETE FROM feed_group + WHERE accountId = :accountId + """ + ) + suspend fun deleteByAccountId(accountId: Int) + + /** + * Get all group IDs associated with a specific feed. + */ + @Query( + """ + SELECT groupId FROM feed_group + WHERE feedId = :feedId + AND accountId = :accountId + """ + ) + suspend fun getGroupIdsByFeed(feedId: String, accountId: Int): List + + /** + * Get all feed IDs associated with a specific group. + */ + @Query( + """ + SELECT feedId FROM feed_group + WHERE groupId = :groupId + AND accountId = :accountId + """ + ) + suspend fun getFeedIdsByGroup(groupId: String, accountId: Int): List + + /** + * Get all groups associated with a specific feed. + */ + @Query( + """ + SELECT g.* FROM `group` g + INNER JOIN feed_group fg ON g.id = fg.groupId + WHERE fg.feedId = :feedId + AND fg.accountId = :accountId + """ + ) + suspend fun getGroupsByFeed(feedId: String, accountId: Int): List + + /** + * Get all feeds associated with a specific group. + * This is the many-to-many equivalent of FeedDao.queryByGroupId + */ + @Query( + """ + SELECT f.* FROM feed f + INNER JOIN feed_group fg ON f.id = fg.feedId + WHERE fg.groupId = :groupId + AND fg.accountId = :accountId + """ + ) + suspend fun getFeedsByGroup(groupId: String, accountId: Int): List + + /** + * Get all feeds associated with a specific group as a Flow. + * Useful for reactive UI updates. + */ + @Query( + """ + SELECT f.* FROM feed f + INNER JOIN feed_group fg ON f.id = fg.feedId + WHERE fg.groupId = :groupId + AND fg.accountId = :accountId + """ + ) + fun getFeedsByGroupAsFlow(groupId: String, accountId: Int): Flow> + + /** + * Get all feed-group associations for an account. + */ + @Query( + """ + SELECT * FROM feed_group + WHERE accountId = :accountId + """ + ) + suspend fun getAllByAccount(accountId: Int): List + + /** + * Check if a feed-group association exists. + */ + @Query( + """ + SELECT COUNT(*) FROM feed_group + WHERE feedId = :feedId + AND groupId = :groupId + AND accountId = :accountId + """ + ) + suspend fun exists(feedId: String, groupId: String, accountId: Int): Int + + /** + * Get the count of feeds in a specific group. + */ + @Query( + """ + SELECT COUNT(*) FROM feed_group + WHERE groupId = :groupId + AND accountId = :accountId + """ + ) + suspend fun getFeedCountByGroup(groupId: String, accountId: Int): Int + + /** + * Get the count of groups a feed belongs to. + */ + @Query( + """ + SELECT COUNT(*) FROM feed_group + WHERE feedId = :feedId + AND accountId = :accountId + """ + ) + suspend fun getGroupCountByFeed(feedId: String, accountId: Int): Int + + /** + * Move a feed from one group to another. + * This updates the association, maintaining many-to-many semantics. + * If you want to add to a new group while keeping existing associations, + * use insert() instead. + */ + @Transaction + suspend fun moveFeed( + feedId: String, + fromGroupId: String, + toGroupId: String, + accountId: Int + ) { + deleteByIds(feedId, fromGroupId, accountId) + insert(FeedGroup(feedId = feedId, groupId = toGroupId, accountId = accountId)) + } + + /** + * Replace all groups for a feed with a new set of groups. + * Useful for bulk updates during sync operations. + */ + @Transaction + suspend fun replaceGroupsForFeed(feedId: String, groupIds: List, accountId: Int) { + deleteByFeedId(feedId, accountId) + insertList(groupIds.map { groupId -> + FeedGroup(feedId = feedId, groupId = groupId, accountId = accountId) + }) + } + + /** + * Replace all feeds for a group with a new set of feeds. + * Useful for bulk updates during sync operations. + */ + @Transaction + suspend fun replaceFeedsForGroup(groupId: String, feedIds: List, accountId: Int) { + deleteByGroupId(groupId, accountId) + insertList(feedIds.map { feedId -> + FeedGroup(feedId = feedId, groupId = groupId, accountId = accountId) + }) + } +} diff --git a/app/src/main/java/me/ash/reader/domain/service/AbstractRssRepository.kt b/app/src/main/java/me/ash/reader/domain/service/AbstractRssRepository.kt index 32f66c7ba..6a83f354b 100644 --- a/app/src/main/java/me/ash/reader/domain/service/AbstractRssRepository.kt +++ b/app/src/main/java/me/ash/reader/domain/service/AbstractRssRepository.kt @@ -18,9 +18,11 @@ import me.ash.reader.domain.model.article.Article import me.ash.reader.domain.model.article.ArticleWithFeed import me.ash.reader.domain.model.feed.Feed import me.ash.reader.domain.model.group.Group +import me.ash.reader.domain.model.feedgroup.FeedGroup import me.ash.reader.domain.model.group.GroupWithFeed import me.ash.reader.domain.repository.ArticleDao import me.ash.reader.domain.repository.FeedDao +import me.ash.reader.domain.repository.FeedGroupDao import me.ash.reader.domain.repository.GroupDao import me.ash.reader.infrastructure.android.NotificationHelper import me.ash.reader.infrastructure.preference.KeepArchivedPreference @@ -33,6 +35,7 @@ abstract class AbstractRssRepository( private val articleDao: ArticleDao, private val groupDao: GroupDao, private val feedDao: FeedDao, + private val feedGroupDao: FeedGroupDao, private val workManager: WorkManager, private val rssHelper: RssHelper, private val notificationHelper: NotificationHelper, @@ -75,6 +78,13 @@ abstract class AbstractRssRepository( val articles = searchedFeed.entries.map { rssHelper.buildArticleFromSyndEntry(feed, accountId, it) } feedDao.insert(feed) + feedGroupDao.insert( + FeedGroup( + feedId = feed.id, + groupId = groupId, + accountId = accountId + ) + ) articleDao.insertList(articles.map { it.copy(feedId = feed.id) }) } @@ -318,6 +328,12 @@ abstract class AbstractRssRepository( open suspend fun moveFeed(originGroupId: String, feed: Feed) { updateFeed(feed) + feedGroupDao.moveFeed( + feedId = feed.id, + fromGroupId = originGroupId, + toGroupId = feed.groupId, + accountId = feed.accountId + ) } open suspend fun changeFeedUrl(feed: Feed) { @@ -338,6 +354,8 @@ abstract class AbstractRssRepository( } deleteArticles(group = group, includeStarred = true) feedDao.deleteByGroupId(accountId, group.id) + // Clean up junction table associations + feedGroupDao.deleteByGroupId(group.id, accountId) groupDao.delete(group) } @@ -353,6 +371,7 @@ abstract class AbstractRssRepository( return } deleteArticles(feed = feed, includeStarred = true) + feedGroupDao.deleteByFeedId(feed.id, feed.accountId) feedDao.delete(feed) } diff --git a/app/src/main/java/me/ash/reader/domain/service/AccountService.kt b/app/src/main/java/me/ash/reader/domain/service/AccountService.kt index 42cc1b786..c2388d434 100644 --- a/app/src/main/java/me/ash/reader/domain/service/AccountService.kt +++ b/app/src/main/java/me/ash/reader/domain/service/AccountService.kt @@ -18,10 +18,12 @@ import me.ash.reader.R import me.ash.reader.domain.model.account.Account import me.ash.reader.domain.model.account.AccountType import me.ash.reader.domain.model.feed.Feed +import me.ash.reader.domain.model.feedgroup.FeedGroup import me.ash.reader.domain.model.group.Group import me.ash.reader.domain.repository.AccountDao import me.ash.reader.domain.repository.ArticleDao import me.ash.reader.domain.repository.FeedDao +import me.ash.reader.domain.repository.FeedGroupDao import me.ash.reader.domain.repository.GroupDao import me.ash.reader.infrastructure.di.ApplicationScope import me.ash.reader.infrastructure.preference.SettingsProvider @@ -39,6 +41,7 @@ constructor( private val accountDao: AccountDao, private val groupDao: GroupDao, private val feedDao: FeedDao, + private val feedGroupDao: FeedGroupDao, private val articleDao: ArticleDao, @ApplicationScope private val coroutineScope: CoroutineScope, settingsProvider: SettingsProvider, @@ -101,17 +104,17 @@ constructor( suspend fun initWithDefaultAccount() { val account = addDefaultAccount() val group = getDefaultGroup() - val initialFeed = getInitialFeed(account, group) + val initialFeed = getInitialFeed(account) feedDao.insert(initialFeed) + feedGroupDao.insert(FeedGroup(feedId = initialFeed.id, groupId = group.id, accountId = getCurrentAccountId())) } - private fun getInitialFeed(account: Account, group: Group): Feed = + private fun getInitialFeed(account: Account): Feed = Feed( id = account.id!!.spacerDollar(UUID.randomUUID().toString()), name = "ReadYou Releases", icon = "https://github.com/ReadYouApp.png", url = "https://github.com/ReadYouApp/ReadYou/releases.atom", - groupId = group.id, accountId = account.id, ) diff --git a/app/src/main/java/me/ash/reader/domain/service/FeverRssService.kt b/app/src/main/java/me/ash/reader/domain/service/FeverRssService.kt index 91de4281e..5687eb770 100644 --- a/app/src/main/java/me/ash/reader/domain/service/FeverRssService.kt +++ b/app/src/main/java/me/ash/reader/domain/service/FeverRssService.kt @@ -23,6 +23,7 @@ import me.ash.reader.domain.model.feed.Feed import me.ash.reader.domain.model.group.Group import me.ash.reader.domain.repository.ArticleDao import me.ash.reader.domain.repository.FeedDao +import me.ash.reader.domain.repository.FeedGroupDao import me.ash.reader.domain.repository.GroupDao import me.ash.reader.infrastructure.android.NotificationHelper import me.ash.reader.infrastructure.di.DefaultDispatcher @@ -44,6 +45,7 @@ constructor( @ApplicationContext private val context: Context, private val articleDao: ArticleDao, private val feedDao: FeedDao, + private val feedGroupDao: FeedGroupDao, private val rssHelper: RssHelper, private val notificationHelper: NotificationHelper, private val groupDao: GroupDao, @@ -57,6 +59,7 @@ constructor( articleDao, groupDao, feedDao, + feedGroupDao, workManager, rssHelper, notificationHelper, diff --git a/app/src/main/java/me/ash/reader/domain/service/GoogleReaderRssService.kt b/app/src/main/java/me/ash/reader/domain/service/GoogleReaderRssService.kt index 909bc85fc..8b934efde 100644 --- a/app/src/main/java/me/ash/reader/domain/service/GoogleReaderRssService.kt +++ b/app/src/main/java/me/ash/reader/domain/service/GoogleReaderRssService.kt @@ -33,9 +33,11 @@ import me.ash.reader.domain.model.account.AccountType.Companion.FreshRSS import me.ash.reader.domain.model.account.security.GoogleReaderSecurityKey import me.ash.reader.domain.model.article.Article import me.ash.reader.domain.model.feed.Feed +import me.ash.reader.domain.model.feed.FeedWithGroups import me.ash.reader.domain.model.group.Group import me.ash.reader.domain.repository.ArticleDao import me.ash.reader.domain.repository.FeedDao +import me.ash.reader.domain.repository.FeedGroupDao import me.ash.reader.domain.repository.GroupDao import me.ash.reader.infrastructure.android.NotificationHelper import me.ash.reader.infrastructure.di.DefaultDispatcher @@ -68,6 +70,7 @@ constructor( @ApplicationContext private val context: Context, private val articleDao: ArticleDao, private val feedDao: FeedDao, + private val feedGroupDao: FeedGroupDao, private val rssHelper: RssHelper, private val notificationHelper: NotificationHelper, private val groupDao: GroupDao, @@ -82,6 +85,7 @@ constructor( articleDao, groupDao, feedDao, + feedGroupDao, workManager, rssHelper, notificationHelper, @@ -261,7 +265,7 @@ constructor( requireNotNull(account) { "cannot find account" } check( account.type.id == AccountType.GoogleReader.id || - account.type.id == AccountType.FreshRSS.id + account.type.id == AccountType.FreshRSS.id ) { "account type is invalid" } @@ -291,12 +295,12 @@ constructor( val isFreshRss = account.type.id == FreshRSS.id val remoteReadIds = async { fetchItemIdsAndContinue { - googleReaderAPI.getReadItemIds( - since = lastMonthAt, - continuationId = it, - useIt = isFreshRss, - ) - } + googleReaderAPI.getReadItemIds( + since = lastMonthAt, + continuationId = it, + useIt = isFreshRss, + ) + } .map { it.shortId } .toSet() } @@ -369,13 +373,16 @@ constructor( val feedUrl = it.url ?: it.htmlUrl requireNotNull(feedUrl) { "feed url is null" } val feedId = accountId spacerDollar it.id.ofFeedStreamIdToId() - Feed( - id = feedId, - name = it.title.decodeHTML() ?: context.getString(R.string.empty), - url = feedUrl, - groupId = group.id, - accountId = accountId, - icon = it.iconUrl, + FeedWithGroups( + feed = Feed( + id = feedId, + name = it.title.decodeHTML() + ?: context.getString(R.string.empty), + url = feedUrl, + accountId = accountId, + icon = it.iconUrl, + ), + groups = listOf(group) ) } } @@ -390,29 +397,36 @@ constructor( val deferredList = fetchItemsContentsDeferred( - itemIds = toBeSync.await(), - googleReaderAPI = googleReaderAPI, - accountId = accountId, - unreadIds = remoteUnreadIds.await(), - starredIds = remoteStarredIds.await(), - scope = this, - ) + itemIds = toBeSync.await(), + googleReaderAPI = googleReaderAPI, + accountId = accountId, + unreadIds = remoteUnreadIds.await(), + starredIds = remoteStarredIds.await(), + scope = this, + ) .toMutableList() val remoteGroups = async { groupWithFeedsMap.await().keys.toList() } - val remoteFeeds = async { groupWithFeedsMap.await().values.flatten() } + val remoteFeedWithGroups = async { groupWithFeedsMap.await().values.flatten() } // Handle empty icon for feeds launch { val localFeeds = feedDao.queryAll(accountId) - val remoteFeeds = remoteFeeds.await() - val newFeeds = remoteFeeds.filter { feed -> feed.id !in localFeeds.map { it.id } } + val remoteFeeds = remoteFeedWithGroups.await() + val newFeeds = + remoteFeeds.filter { feedWithGroups -> feedWithGroups.feed.id !in localFeeds.map { it.id } } val feedsWithIconFetched = newFeeds - .filter { it.icon == null } - .map { feed -> - async { feed.copy(icon = rssHelper.queryRssIconLink(feed.url)) } + .filter { it.feed.icon == null } + .map { feedWithGroup -> + async { + feedWithGroup.feed.copy( + icon = rssHelper.queryRssIconLink( + feedWithGroup.feed.url + ) + ) + } } feedsWithIconFetched .awaitAll() @@ -421,7 +435,8 @@ constructor( } groupDao.insertOrUpdate(remoteGroups.await()) - feedDao.insertOrUpdate(remoteFeeds.await()) + feedDao.insertOrUpdate(remoteFeedWithGroups.await().map { it.feed }) + feedGroupDao.insert() val notificationFeeds = feedDao.queryNotificationEnabled(accountId).associateBy { it.id } @@ -430,21 +445,21 @@ constructor( if (deferredList.isNotEmpty()) { launch { - whileSelect { - for (deferred in deferredList) { - deferred.onAwait { - articleDao.insertList(it) - articlesToNotify.addAll( - it.fastFilter { - it.isUnread && notificationFeedIds.contains(it.feedId) - } - ) - deferredList.remove(deferred) - deferredList.isNotEmpty() - } + whileSelect { + for (deferred in deferredList) { + deferred.onAwait { + articleDao.insertList(it) + articlesToNotify.addAll( + it.fastFilter { + it.isUnread && notificationFeedIds.contains(it.feedId) + } + ) + deferredList.remove(deferred) + deferredList.isNotEmpty() } } } + } .invokeOnCompletion { launch { articlesToNotify @@ -479,7 +494,7 @@ constructor( .forEach { super.deleteGroup(it, true) } feedDao .queryAll(accountId) - .filter { it.id !in remoteFeeds.await().map { feed -> feed.id } } + .filter { it.id !in remoteFeedWithGroups.await().map { item -> item.feed.id } } .forEach { super.deleteFeed(it, true) } accountService.update(account.copy(updateAt = Date())) @@ -526,24 +541,24 @@ constructor( val remoteUnreadIds = async { fetchItemIdsAndContinue { - googleReaderAPI.getItemIdsForFeed( - feedId = feedId.dollarLast(), - filterRead = true, - continuationId = it, - ) - } + googleReaderAPI.getItemIdsForFeed( + feedId = feedId.dollarLast(), + filterRead = true, + continuationId = it, + ) + } .map { it.shortId } .toSet() } val remoteAllIds = async { fetchItemIdsAndContinue { - googleReaderAPI.getItemIdsForFeed( - feedId = feedId.dollarLast(), - filterRead = false, - continuationId = it, - ) - } + googleReaderAPI.getItemIdsForFeed( + feedId = feedId.dollarLast(), + filterRead = false, + continuationId = it, + ) + } .map { it.shortId } .toSet() } @@ -685,13 +700,13 @@ constructor( starredIds: Set, ): List
= supervisorScope { fetchItemsContentsDeferred( - itemIds = itemIds, - googleReaderAPI = googleReaderAPI, - accountId = accountId, - unreadIds = unreadIds, - starredIds = starredIds, - scope = this, - ) + itemIds = itemIds, + googleReaderAPI = googleReaderAPI, + accountId = accountId, + unreadIds = unreadIds, + starredIds = starredIds, + scope = this, + ) .awaitAll() .flatten() } @@ -715,28 +730,28 @@ constructor( when { groupId != null -> { if (before == null) { - articleDao.queryMetadataByGroupIdWhenIsUnread( - accountId, - groupId, - !isUnread, - ) - } else { - articleDao.queryMetadataByGroupIdWhenIsUnread( - accountId, - groupId, - !isUnread, - before, - ) - } + articleDao.queryMetadataByGroupIdWhenIsUnread( + accountId, + groupId, + !isUnread, + ) + } else { + articleDao.queryMetadataByGroupIdWhenIsUnread( + accountId, + groupId, + !isUnread, + before, + ) + } .map { it.id.dollarLast() } } feedId != null -> { if (before == null) { - articleDao.queryMetadataByFeedId(accountId, feedId, !isUnread) - } else { - articleDao.queryMetadataByFeedId(accountId, feedId, !isUnread, before) - } + articleDao.queryMetadataByFeedId(accountId, feedId, !isUnread) + } else { + articleDao.queryMetadataByFeedId(accountId, feedId, !isUnread, before) + } .map { it.id.dollarLast() } } @@ -746,10 +761,10 @@ constructor( else -> { if (before == null) { - articleDao.queryMetadataAll(accountId, !isUnread) - } else { - articleDao.queryMetadataAll(accountId, !isUnread, before) - } + articleDao.queryMetadataAll(accountId, !isUnread) + } else { + articleDao.queryMetadataAll(accountId, !isUnread, before) + } .map { it.id.dollarLast() } } } diff --git a/app/src/main/java/me/ash/reader/domain/service/LocalRssService.kt b/app/src/main/java/me/ash/reader/domain/service/LocalRssService.kt index 509f7b547..2513d0f75 100644 --- a/app/src/main/java/me/ash/reader/domain/service/LocalRssService.kt +++ b/app/src/main/java/me/ash/reader/domain/service/LocalRssService.kt @@ -19,6 +19,7 @@ import me.ash.reader.domain.model.feed.Feed import me.ash.reader.domain.model.feed.FeedWithArticle import me.ash.reader.domain.repository.ArticleDao import me.ash.reader.domain.repository.FeedDao +import me.ash.reader.domain.repository.FeedGroupDao import me.ash.reader.domain.repository.GroupDao import me.ash.reader.infrastructure.android.NotificationHelper import me.ash.reader.infrastructure.di.DefaultDispatcher @@ -34,6 +35,7 @@ constructor( @ApplicationContext private val context: Context, private val articleDao: ArticleDao, private val feedDao: FeedDao, + private val feedGroupDao: FeedGroupDao, private val rssHelper: RssHelper, private val notificationHelper: NotificationHelper, private val groupDao: GroupDao, @@ -47,6 +49,7 @@ constructor( articleDao, groupDao, feedDao, + feedGroupDao, workManager, rssHelper, notificationHelper, diff --git a/app/src/main/java/me/ash/reader/infrastructure/db/AndroidDatabase.kt b/app/src/main/java/me/ash/reader/infrastructure/db/AndroidDatabase.kt index 925b345b7..18a71dcc4 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/db/AndroidDatabase.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/db/AndroidDatabase.kt @@ -13,18 +13,20 @@ import me.ash.reader.domain.model.group.Group import me.ash.reader.domain.repository.AccountDao import me.ash.reader.domain.repository.ArticleDao import me.ash.reader.domain.repository.FeedDao +import me.ash.reader.domain.repository.FeedGroupDao import me.ash.reader.domain.repository.GroupDao import me.ash.reader.infrastructure.preference.* import me.ash.reader.ui.ext.toInt import java.util.* @Database( - entities = [Account::class, Feed::class, Article::class, Group::class, ArchivedArticle::class], - version = 7, + entities = [Account::class, Feed::class, Article::class, Group::class, ArchivedArticle::class, me.ash.reader.domain.model.feedgroup.FeedGroup::class], + version = 10, autoMigrations = [ AutoMigration(from = 5, to = 6), AutoMigration(from = 5, to = 7), AutoMigration(from = 6, to = 7), + AutoMigration(from = 7, to = 8), ] ) @TypeConverters( @@ -43,6 +45,7 @@ abstract class AndroidDatabase : RoomDatabase() { abstract fun feedDao(): FeedDao abstract fun articleDao(): ArticleDao abstract fun groupDao(): GroupDao + abstract fun feedGroupDao(): FeedGroupDao companion object { @@ -80,6 +83,8 @@ val allMigrations = arrayOf( MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, + MIGRATION_8_9, + MIGRATION_9_10, ) @Suppress("ClassName") @@ -159,3 +164,24 @@ object MIGRATION_4_5 : Migration(4, 5) { ) } } + +@Suppress("ClassName") +object MIGRATION_8_9 : Migration(8, 9) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + INSERT INTO feed_group (feedId, groupId, accountId) + SELECT id, groupId, accountId FROM feed + """.trimIndent() + ) + } +} + +@Suppress("ClassName") +object MIGRATION_9_10 : Migration(9, 10) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("CREATE INDEX IF NOT EXISTS index_feed_group_feedId ON feed_group(feedId)") + database.execSQL("CREATE INDEX IF NOT EXISTS index_feed_group_groupId ON feed_group(groupId)") + database.execSQL("CREATE INDEX IF NOT EXISTS index_feed_group_accountId ON feed_group(accountId)") + } +} diff --git a/app/src/main/java/me/ash/reader/infrastructure/di/AccountServiceModule.kt b/app/src/main/java/me/ash/reader/infrastructure/di/AccountServiceModule.kt index d510f591b..6df657299 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/di/AccountServiceModule.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/di/AccountServiceModule.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.CoroutineScope import me.ash.reader.domain.repository.AccountDao import me.ash.reader.domain.repository.ArticleDao import me.ash.reader.domain.repository.FeedDao +import me.ash.reader.domain.repository.FeedGroupDao import me.ash.reader.domain.repository.GroupDao import me.ash.reader.domain.service.AccountService import me.ash.reader.domain.service.RssService @@ -27,6 +28,7 @@ object AccountServiceModule { groupDao: GroupDao, feedDao: FeedDao, articleDao: ArticleDao, + feedGroupDao: FeedGroupDao, @ApplicationScope coroutineScope: CoroutineScope, settingsProvider: SettingsProvider, ): AccountService { @@ -36,8 +38,9 @@ object AccountServiceModule { groupDao = groupDao, feedDao = feedDao, articleDao = articleDao, + feedGroupDao = feedGroupDao, coroutineScope = coroutineScope, settingsProvider = settingsProvider, ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/me/ash/reader/infrastructure/di/DatabaseModule.kt b/app/src/main/java/me/ash/reader/infrastructure/di/DatabaseModule.kt index 98e4dc522..93ce709c6 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/di/DatabaseModule.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/di/DatabaseModule.kt @@ -9,6 +9,7 @@ import dagger.hilt.components.SingletonComponent import me.ash.reader.domain.repository.AccountDao import me.ash.reader.domain.repository.ArticleDao import me.ash.reader.domain.repository.FeedDao +import me.ash.reader.domain.repository.FeedGroupDao import me.ash.reader.domain.repository.GroupDao import me.ash.reader.infrastructure.db.AndroidDatabase import javax.inject.Singleton @@ -20,6 +21,7 @@ import javax.inject.Singleton * - [FeedDao] * - [GroupDao] * - [AccountDao] + * - [FeedGroupDao] */ @Module @InstallIn(SingletonComponent::class) @@ -45,6 +47,11 @@ object DatabaseModule { fun provideAccountDao(androidDatabase: AndroidDatabase): AccountDao = androidDatabase.accountDao() + @Provides + @Singleton + fun provideFeedGroupDao(androidDatabase: AndroidDatabase): FeedGroupDao = + androidDatabase.feedGroupDao() + @Provides @Singleton fun provideReaderDatabase(@ApplicationContext context: Context): AndroidDatabase = diff --git a/app/src/main/java/me/ash/reader/ui/page/adaptive/ArticleListReaderViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/adaptive/ArticleListReaderViewModel.kt index 3d9a5fa06..6f8354ee5 100644 --- a/app/src/main/java/me/ash/reader/ui/page/adaptive/ArticleListReaderViewModel.kt +++ b/app/src/main/java/me/ash/reader/ui/page/adaptive/ArticleListReaderViewModel.kt @@ -71,9 +71,8 @@ constructor( val flowUiState: StateFlow = articleListUseCase.pagerFlow - .combine(groupWithFeedsListUseCase.groupWithFeedListFlow) { - pagerData, - groupWithFeedsList -> + .combine(groupWithFeedsListUseCase.groupWithFeedListFlow) { pagerData, + groupWithFeedsList -> val filterState = pagerData.filterState var nextFilterState: FilterState? = null if (filterState.group != null) { @@ -191,7 +190,7 @@ constructor( viewModelScope.launch { if ( settingsProvider.settings.pullToSwitchFeed == - PullToLoadNextFeedPreference.MarkAsReadAndLoadNextFeed + PullToLoadNextFeedPreference.MarkAsReadAndLoadNextFeed ) { markAllAsRead() } @@ -226,13 +225,7 @@ constructor( val filterState = filterStateUseCase.filterStateFlow.value val service = rssService.get() when (service) { - is LocalRssService -> - service.doSyncOneTime( - feedId = filterState.feed?.id, - groupId = filterState.group?.id, - ) - - is GoogleReaderRssService -> + is LocalRssService, is GoogleReaderRssService -> service.doSyncOneTime( feedId = filterState.feed?.id, groupId = filterState.group?.id, @@ -284,7 +277,7 @@ constructor( } else { snapshotList.find { item -> item is ArticleFlowItem.Article && - item.articleWithFeed.article.id == articleId + item.articleWithFeed.article.id == articleId } as? ArticleFlowItem.Article } @@ -302,13 +295,13 @@ constructor( } _readerState.update { it.copy( - articleId = article.id, - feedName = feed.name, - title = article.title, - author = article.author, - link = article.link, - publishedDate = article.date, - ) + articleId = article.id, + feedName = feed.name, + title = article.title, + author = article.author, + link = article.link, + publishedDate = article.date, + ) .prefetchArticleId() .renderContent(this) }