diff --git a/app/schemas/com.nononsenseapps.feeder.db.room.AppDatabase/34.json b/app/schemas/com.nononsenseapps.feeder.db.room.AppDatabase/34.json new file mode 100644 index 000000000..16db65aa5 --- /dev/null +++ b/app/schemas/com.nononsenseapps.feeder.db.room.AppDatabase/34.json @@ -0,0 +1,766 @@ +{ + "formatVersion": 1, + "database": { + "version": 34, + "identityHash": "a65aefd269cc6beef005c1002b1edd87", + "entities": [ + { + "tableName": "feeds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `custom_title` TEXT NOT NULL, `url` TEXT NOT NULL, `tag` TEXT NOT NULL, `notify` INTEGER NOT NULL, `image_url` TEXT, `last_sync` INTEGER NOT NULL, `response_hash` INTEGER NOT NULL, `fulltext_by_default` INTEGER NOT NULL, `open_articles_with` TEXT NOT NULL, `alternate_id` INTEGER NOT NULL, `currently_syncing` INTEGER NOT NULL, `when_modified` INTEGER NOT NULL, `site_fetched` INTEGER NOT NULL, `skip_duplicates` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "customTitle", + "columnName": "custom_title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notify", + "columnName": "notify", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSync", + "columnName": "last_sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "responseHash", + "columnName": "response_hash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fullTextByDefault", + "columnName": "fulltext_by_default", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "openArticlesWith", + "columnName": "open_articles_with", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alternateId", + "columnName": "alternate_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentlySyncing", + "columnName": "currently_syncing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "whenModified", + "columnName": "when_modified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "siteFetched", + "columnName": "site_fetched", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "skipDuplicates", + "columnName": "skip_duplicates", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_feeds_url", + "unique": true, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_feeds_url` ON `${TABLE_NAME}` (`url`)" + }, + { + "name": "index_feeds_id_url_title", + "unique": true, + "columnNames": [ + "id", + "url", + "title" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_feeds_id_url_title` ON `${TABLE_NAME}` (`id`, `url`, `title`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "feed_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `plain_title` TEXT NOT NULL, `plain_snippet` TEXT NOT NULL, `image_url` TEXT, `image_from_body` INTEGER NOT NULL, `enclosure_link` TEXT, `enclosure_type` TEXT, `author` TEXT, `pub_date` TEXT, `link` TEXT, `unread` INTEGER NOT NULL, `notified` INTEGER NOT NULL, `feed_id` INTEGER, `first_synced_time` INTEGER NOT NULL, `primary_sort_time` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `fulltext_downloaded` INTEGER NOT NULL, `read_time` INTEGER, `word_count` INTEGER NOT NULL, `word_count_full` INTEGER NOT NULL, FOREIGN KEY(`feed_id`) REFERENCES `feeds`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "guid", + "columnName": "guid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "plainTitle", + "columnName": "plain_title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "plainSnippet", + "columnName": "plain_snippet", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailImage", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "imageFromBody", + "columnName": "image_from_body", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enclosureLink", + "columnName": "enclosure_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enclosureType", + "columnName": "enclosure_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pubDate", + "columnName": "pub_date", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "oldUnread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notified", + "columnName": "notified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "feedId", + "columnName": "feed_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "firstSyncedTime", + "columnName": "first_synced_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "primarySortTime", + "columnName": "primary_sort_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "oldPinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fullTextDownloaded", + "columnName": "fulltext_downloaded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "readTime", + "columnName": "read_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "wordCount", + "columnName": "word_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCountFull", + "columnName": "word_count_full", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_feed_items_guid_feed_id", + "unique": true, + "columnNames": [ + "guid", + "feed_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_feed_items_guid_feed_id` ON `${TABLE_NAME}` (`guid`, `feed_id`)" + }, + { + "name": "index_feed_items_feed_id", + "unique": false, + "columnNames": [ + "feed_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_items_feed_id` ON `${TABLE_NAME}` (`feed_id`)" + }, + { + "name": "idx_feed_items_cursor", + "unique": true, + "columnNames": [ + "primary_sort_time", + "pub_date", + "id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `idx_feed_items_cursor` ON `${TABLE_NAME}` (`primary_sort_time`, `pub_date`, `id`)" + } + ], + "foreignKeys": [ + { + "table": "feeds", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "feed_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "blocklist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `glob_pattern` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "globPattern", + "columnName": "glob_pattern", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_blocklist_glob_pattern", + "unique": true, + "columnNames": [ + "glob_pattern" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_blocklist_glob_pattern` ON `${TABLE_NAME}` (`glob_pattern`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "sync_remote", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `sync_chain_id` TEXT NOT NULL, `latest_message_timestamp` INTEGER NOT NULL, `device_id` INTEGER NOT NULL, `device_name` TEXT NOT NULL, `secret_key` TEXT NOT NULL, `last_feeds_remote_hash` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "syncChainId", + "columnName": "sync_chain_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "latestMessageTimestamp", + "columnName": "latest_message_timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "device_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceName", + "columnName": "device_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secretKey", + "columnName": "secret_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastFeedsRemoteHash", + "columnName": "last_feeds_remote_hash", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "read_status_synced", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sync_remote` INTEGER NOT NULL, `feed_item` INTEGER NOT NULL, FOREIGN KEY(`feed_item`) REFERENCES `feed_items`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`sync_remote`) REFERENCES `sync_remote`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sync_remote", + "columnName": "sync_remote", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "feed_item", + "columnName": "feed_item", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_read_status_synced_feed_item_sync_remote", + "unique": true, + "columnNames": [ + "feed_item", + "sync_remote" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_read_status_synced_feed_item_sync_remote` ON `${TABLE_NAME}` (`feed_item`, `sync_remote`)" + }, + { + "name": "index_read_status_synced_feed_item", + "unique": false, + "columnNames": [ + "feed_item" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_read_status_synced_feed_item` ON `${TABLE_NAME}` (`feed_item`)" + }, + { + "name": "index_read_status_synced_sync_remote", + "unique": false, + "columnNames": [ + "sync_remote" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_read_status_synced_sync_remote` ON `${TABLE_NAME}` (`sync_remote`)" + } + ], + "foreignKeys": [ + { + "table": "feed_items", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "feed_item" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "sync_remote", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sync_remote" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "remote_read_mark", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sync_remote` INTEGER NOT NULL, `feed_url` TEXT NOT NULL, `guid` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`sync_remote`) REFERENCES `sync_remote`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sync_remote", + "columnName": "sync_remote", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "feedUrl", + "columnName": "feed_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "guid", + "columnName": "guid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_remote_read_mark_sync_remote_feed_url_guid", + "unique": true, + "columnNames": [ + "sync_remote", + "feed_url", + "guid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_remote_read_mark_sync_remote_feed_url_guid` ON `${TABLE_NAME}` (`sync_remote`, `feed_url`, `guid`)" + }, + { + "name": "index_remote_read_mark_feed_url_guid", + "unique": false, + "columnNames": [ + "feed_url", + "guid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_remote_read_mark_feed_url_guid` ON `${TABLE_NAME}` (`feed_url`, `guid`)" + }, + { + "name": "index_remote_read_mark_sync_remote", + "unique": false, + "columnNames": [ + "sync_remote" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_remote_read_mark_sync_remote` ON `${TABLE_NAME}` (`sync_remote`)" + }, + { + "name": "index_remote_read_mark_timestamp", + "unique": false, + "columnNames": [ + "timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_remote_read_mark_timestamp` ON `${TABLE_NAME}` (`timestamp`)" + } + ], + "foreignKeys": [ + { + "table": "sync_remote", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sync_remote" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "remote_feed", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sync_remote` INTEGER NOT NULL, `url` TEXT NOT NULL, FOREIGN KEY(`sync_remote`) REFERENCES `sync_remote`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncRemote", + "columnName": "sync_remote", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_remote_feed_sync_remote_url", + "unique": true, + "columnNames": [ + "sync_remote", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_remote_feed_sync_remote_url` ON `${TABLE_NAME}` (`sync_remote`, `url`)" + }, + { + "name": "index_remote_feed_url", + "unique": false, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_remote_feed_url` ON `${TABLE_NAME}` (`url`)" + }, + { + "name": "index_remote_feed_sync_remote", + "unique": false, + "columnNames": [ + "sync_remote" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_remote_feed_sync_remote` ON `${TABLE_NAME}` (`sync_remote`)" + } + ], + "foreignKeys": [ + { + "table": "sync_remote", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sync_remote" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "sync_device", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sync_remote` INTEGER NOT NULL, `device_id` INTEGER NOT NULL, `device_name` TEXT NOT NULL, FOREIGN KEY(`sync_remote`) REFERENCES `sync_remote`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncRemote", + "columnName": "sync_remote", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "device_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceName", + "columnName": "device_name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_sync_device_sync_remote_device_id", + "unique": true, + "columnNames": [ + "sync_remote", + "device_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_sync_device_sync_remote_device_id` ON `${TABLE_NAME}` (`sync_remote`, `device_id`)" + }, + { + "name": "index_sync_device_sync_remote", + "unique": false, + "columnNames": [ + "sync_remote" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sync_device_sync_remote` ON `${TABLE_NAME}` (`sync_remote`)" + } + ], + "foreignKeys": [ + { + "table": "sync_remote", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sync_remote" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "feeds_with_items_for_nav_drawer", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS select feeds.id as feed_id, item_id, case when custom_title is '' then title else custom_title end as display_title, tag, image_url, unread, bookmarked\n from feeds\n left join (\n select id as item_id, feed_id, read_time is null as unread, bookmarked\n from feed_items\n where not exists(select 1 from blocklist where lower(feed_items.plain_title) glob blocklist.glob_pattern)\n )\n ON feeds.id = feed_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, 'a65aefd269cc6beef005c1002b1edd87')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/nononsenseapps/feeder/db/room/TestMigrationFrom33To34.kt b/app/src/androidTest/java/com/nononsenseapps/feeder/db/room/TestMigrationFrom33To34.kt new file mode 100644 index 000000000..ae1b50628 --- /dev/null +++ b/app/src/androidTest/java/com/nononsenseapps/feeder/db/room/TestMigrationFrom33To34.kt @@ -0,0 +1,69 @@ +package com.nononsenseapps.feeder.db.room + +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import androidx.test.platform.app.InstrumentationRegistry +import com.nononsenseapps.feeder.FeederApplication +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.kodein.di.DI +import org.kodein.di.DIAware +import org.kodein.di.android.closestDI +import kotlin.test.assertEquals + +@RunWith(AndroidJUnit4::class) +@LargeTest +class TestMigrationFrom33To34 : DIAware { + private val dbName = "testDb" + private val feederApplication: FeederApplication = ApplicationProvider.getApplicationContext() + override val di: DI by closestDI(feederApplication) + + @Rule + @JvmField + val testHelper: MigrationTestHelper = + MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + AppDatabase::class.java, + emptyList(), + FrameworkSQLiteOpenHelperFactory(), + ) + + @Test + fun migrate() { + @Suppress("SimpleRedundantLet") + testHelper.createDatabase(dbName, FROM_VERSION).let { oldDB -> + oldDB.execSQL( + """ + INSERT INTO feeds(id, title, url, custom_title, tag, notify, last_sync, response_hash, fulltext_by_default, open_articles_with, alternate_id, currently_syncing, when_modified, site_fetched, skip_duplicates) + VALUES(1, 'feed', 'http://url', '', '', 0, 0, 666, 0, '', 0, 0, 0, 0, 0) + """.trimIndent(), + ) + } + val db = + testHelper.runMigrationsAndValidate( + dbName, + TO_VERSION, + true, + MigrationFrom33To34(di), + ) + + db.query( + """ + select feed_id from feeds_with_items_for_nav_drawer + """.trimIndent(), + ).use { + assert(it.count == 1) + assert(it.moveToFirst()) + assertEquals(1, it.getLong(0)) + } + } + + companion object { + private const val FROM_VERSION = 33 + private const val TO_VERSION = 34 + } +} diff --git a/app/src/main/java/com/nononsenseapps/feeder/archmodel/FeedStore.kt b/app/src/main/java/com/nononsenseapps/feeder/archmodel/FeedStore.kt index d53f895b2..e33812016 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/archmodel/FeedStore.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/archmodel/FeedStore.kt @@ -1,19 +1,16 @@ package com.nononsenseapps.feeder.archmodel import android.database.sqlite.SQLiteConstraintException +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData import com.nononsenseapps.feeder.db.room.Feed import com.nononsenseapps.feeder.db.room.FeedDao import com.nononsenseapps.feeder.db.room.FeedForSettings import com.nononsenseapps.feeder.db.room.FeedTitle import com.nononsenseapps.feeder.db.room.ID_UNSET import com.nononsenseapps.feeder.model.FeedUnreadCount -import com.nononsenseapps.feeder.ui.compose.navdrawer.DrawerFeed -import com.nononsenseapps.feeder.ui.compose.navdrawer.DrawerItemWithUnreadCount -import com.nononsenseapps.feeder.ui.compose.navdrawer.DrawerTag -import com.nononsenseapps.feeder.ui.compose.navdrawer.DrawerTop -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.mapLatest import org.kodein.di.DI import org.kodein.di.DIAware import org.kodein.di.instance @@ -64,59 +61,19 @@ class FeedStore(override val di: DI) : DIAware { val feedForSettings: Flow> = feedDao.loadFlowOfFeedsForSettings() - @OptIn(ExperimentalCoroutinesApi::class) - val drawerItemsWithUnreadCounts: Flow> = - feedDao.loadFlowOfFeedsWithUnreadCounts() - .mapLatest { feeds -> - // TODO would like to have a throttle here. Emit first immediately - // then at most every X ms (including latest item - // Must emit first immediately or the feed list will have a delay - mapFeedsToSortedDrawerItems(feeds) - } - - private fun mapFeedsToSortedDrawerItems(feeds: List): List { - var topTag = DrawerTop(unreadCount = 0, totalChildren = 0) - val tags: MutableMap = mutableMapOf() - val data: MutableList = mutableListOf() - - for (feedDbo in feeds) { - val feed = - DrawerFeed( - unreadCount = feedDbo.unreadCount, - tag = feedDbo.tag, - id = feedDbo.id, - displayTitle = feedDbo.displayTitle, - imageUrl = feedDbo.imageUrl, - ) - - data.add(feed) - topTag = - topTag.copy( - unreadCount = topTag.unreadCount + feed.unreadCount, - totalChildren = topTag.totalChildren + 1, - ) - - if (feed.tag.isNotEmpty()) { - val tag = - tags[feed.tag] ?: DrawerTag( - tag = feed.tag, - unreadCount = 0, - uiId = getTagUiId(feed.tag), - totalChildren = 0, - ) - tags[feed.tag] = - tag.copy( - unreadCount = tag.unreadCount + feed.unreadCount, - totalChildren = tag.totalChildren + 1, - ) - } + fun getPagedNavDrawerItems(expandedTags: Set): Flow> = + Pager( + config = + PagingConfig( + pageSize = 10, + initialLoadSize = 50, + prefetchDistance = 50, + jumpThreshold = 50, + ), + ) { + feedDao.getPagedNavDrawerItems(expandedTags) } - - data.add(topTag) - data.addAll(tags.values) - - return data.sorted() - } + .flow fun getFeedTitles( feedId: Long, @@ -165,10 +122,6 @@ class FeedStore(override val di: DI) : DIAware { return feedDao.getFeedsOrderedByUrl() } - fun getFlowOfFeedsOrderedByUrl(): Flow> { - return feedDao.getFlowOfFeedsOrderedByUrl() - } - suspend fun deleteFeed(url: URL) { feedDao.deleteFeedWithUrl(url) } diff --git a/app/src/main/java/com/nononsenseapps/feeder/archmodel/Repository.kt b/app/src/main/java/com/nononsenseapps/feeder/archmodel/Repository.kt index db1bd2631..3a490843e 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/archmodel/Repository.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/archmodel/Repository.kt @@ -24,6 +24,7 @@ import com.nononsenseapps.feeder.db.room.ID_UNSET import com.nononsenseapps.feeder.db.room.RemoteFeed import com.nononsenseapps.feeder.db.room.SyncDevice import com.nononsenseapps.feeder.db.room.SyncRemote +import com.nononsenseapps.feeder.model.FeedUnreadCount import com.nononsenseapps.feeder.model.ThumbnailImage import com.nononsenseapps.feeder.model.workmanager.SyncServiceSendReadWorker import com.nononsenseapps.feeder.model.workmanager.requestFeedSync @@ -33,7 +34,6 @@ import com.nononsenseapps.feeder.sync.SyncRestClient import com.nononsenseapps.feeder.ui.compose.feed.FeedListItem import com.nononsenseapps.feeder.ui.compose.feedarticle.FeedListFilter import com.nononsenseapps.feeder.ui.compose.feedarticle.emptyFeedListFilter -import com.nononsenseapps.feeder.ui.compose.navdrawer.DrawerItemWithUnreadCount import com.nononsenseapps.feeder.util.Either import com.nononsenseapps.feeder.util.addDynamicShortcutToFeed import com.nononsenseapps.feeder.util.logDebug @@ -495,8 +495,10 @@ class Repository(override val di: DI) : DIAware { val allTags: Flow> = feedStore.allTags - val drawerItemsWithUnreadCounts: Flow> = - feedStore.drawerItemsWithUnreadCounts + fun getPagedNavDrawerItems(): Flow> = + expandedTags.flatMapLatest { + feedStore.getPagedNavDrawerItems(it) + } val getUnreadBookmarksCount get() = diff --git a/app/src/main/java/com/nononsenseapps/feeder/db/room/AppDatabase.kt b/app/src/main/java/com/nononsenseapps/feeder/db/room/AppDatabase.kt index 5c93f32ea..c22a7bed8 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/db/room/AppDatabase.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/db/room/AppDatabase.kt @@ -50,7 +50,10 @@ private const val LOG_TAG = "FEEDER_APPDB" RemoteFeed::class, SyncDevice::class, ], - version = 33, + views = [ + FeedsWithItemsForNavDrawer::class, + ], + version = 34, ) @TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { @@ -128,12 +131,30 @@ fun getAllMigrations(di: DI) = MigrationFrom30To31(di), MigrationFrom31To32(di), MigrationFrom32To33(di), + MigrationFrom33To34(di), ) /* * 6 represents legacy database * 7 represents new Room database */ +class MigrationFrom33To34(override val di: DI) : Migration(33, 34), DIAware { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE VIEW `feeds_with_items_for_nav_drawer` AS select feeds.id as feed_id, item_id, case when custom_title is '' then title else custom_title end as display_title, tag, image_url, unread, bookmarked + from feeds + left join ( + select id as item_id, feed_id, read_time is null as unread, bookmarked + from feed_items + where not exists(select 1 from blocklist where lower(feed_items.plain_title) glob blocklist.glob_pattern) + ) + ON feeds.id = feed_id + """.trimIndent(), + ) + } +} + class MigrationFrom32To33(override val di: DI) : Migration(32, 33), DIAware { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL( diff --git a/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedDao.kt b/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedDao.kt index 34e2ff7dc..37b64d5d7 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedDao.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedDao.kt @@ -1,6 +1,7 @@ package com.nononsenseapps.feeder.db.room import android.database.Cursor +import androidx.paging.PagingSource import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert @@ -106,18 +107,31 @@ interface FeedDao { @Query( """ - SELECT id, title, url, tag, custom_title, notify, currently_syncing, image_url, unread_count - FROM feeds - LEFT JOIN (SELECT COUNT(1) AS unread_count, feed_id - FROM feed_items - WHERE read_time is null - AND NOT EXISTS (SELECT 1 FROM blocklist WHERE lower(feed_items.plain_title) GLOB blocklist.glob_pattern) - GROUP BY feed_id - ) - ON feeds.id = feed_id - """, + -- all items + select $ID_ALL_FEEDS as id, '' as display_title, '' as tag, '' as image_url, sum(unread) as unread_count, 0 as expanded, 0 as sort_section, 0 as sort_tag_or_feed + from feeds_with_items_for_nav_drawer + -- starred + union + select $ID_SAVED_ARTICLES as id, '' as display_title, '' as tag, '' as image_url, sum(unread) as unread_count, 0 as expanded, 1 as sort_section, 0 as sort_tag_or_feed + from feeds_with_items_for_nav_drawer + where bookmarked + -- tags + union + select $ID_UNSET as id, tag as display_title, tag, '' as image_url, sum(unread) as unread_count, tag in (:expandedTags) as expanded, 2 as sort_section, 0 as sort_tag_or_feed + from feeds_with_items_for_nav_drawer + where tag is not '' + group by tag + -- feeds + union + select feed_id as id, display_title, tag, image_url, sum(unread) as unread_count, 0 as expanded, case when tag is '' then 3 else 2 end as sort_section, 1 as sort_tag_or_feed + from feeds_with_items_for_nav_drawer + where tag is '' or tag in (:expandedTags) + group by feed_id + -- sort them + order by sort_section, tag, sort_tag_or_feed, display_title + """, ) - fun loadFlowOfFeedsWithUnreadCounts(): Flow> + fun getPagedNavDrawerItems(expandedTags: Set): PagingSource @Query("UPDATE feeds SET notify = :notify WHERE id IS :id") suspend fun setNotify( diff --git a/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedsWithItemsForNavDrawer.kt b/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedsWithItemsForNavDrawer.kt new file mode 100644 index 000000000..9ac373142 --- /dev/null +++ b/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedsWithItemsForNavDrawer.kt @@ -0,0 +1,31 @@ +package com.nononsenseapps.feeder.db.room + +import androidx.room.ColumnInfo +import androidx.room.DatabaseView + +@DatabaseView( + value = """ + select feeds.id as feed_id, item_id, case when custom_title is '' then title else custom_title end as display_title, tag, image_url, unread, bookmarked + from feeds + left join ( + select id as item_id, feed_id, read_time is null as unread, bookmarked + from feed_items + where not exists(select 1 from blocklist where lower(feed_items.plain_title) glob blocklist.glob_pattern) + ) + ON feeds.id = feed_id + """, + viewName = "feeds_with_items_for_nav_drawer", +) +data class FeedsWithItemsForNavDrawer( + @ColumnInfo(name = "feed_id") + val feedId: Long, + val tag: String, + @ColumnInfo(name = "display_title") + val displayTitle: String, + @ColumnInfo(name = "image_url") + val imageUrl: String?, + val unread: Boolean, + @ColumnInfo(name = "item_id") + val itemId: Long?, + val bookmarked: Boolean, +) diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/FeedUnreadCount.kt b/app/src/main/java/com/nononsenseapps/feeder/model/FeedUnreadCount.kt index ab96e5005..04b21fe20 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/model/FeedUnreadCount.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/model/FeedUnreadCount.kt @@ -2,78 +2,20 @@ package com.nononsenseapps.feeder.model import androidx.room.ColumnInfo import androidx.room.Ignore -import com.nononsenseapps.feeder.db.COL_CURRENTLY_SYNCING -import com.nononsenseapps.feeder.db.room.ID_ALL_FEEDS import com.nononsenseapps.feeder.db.room.ID_UNSET -import com.nononsenseapps.feeder.util.sloppyLinkToStrictURLNoThrows +import com.nononsenseapps.feeder.ui.compose.navdrawer.FeedIdTag import java.net.URL data class FeedUnreadCount @Ignore constructor( - var id: Long = ID_UNSET, - var title: String = "", - var url: URL = sloppyLinkToStrictURLNoThrows(""), - var tag: String = "", - @ColumnInfo(name = "custom_title") - var customTitle: String = "", - var notify: Boolean = false, - @ColumnInfo(name = COL_CURRENTLY_SYNCING) var currentlySyncing: Boolean = false, + override var id: Long = ID_UNSET, + @ColumnInfo(name = "display_title") + var displayTitle: String = "", + override var tag: String = "", @ColumnInfo(name = "image_url") var imageUrl: URL? = null, @ColumnInfo(name = "unread_count") var unreadCount: Int = 0, - ) : Comparable { + var expanded: Boolean = false, + ) : FeedIdTag { constructor() : this(id = ID_UNSET) - - val displayTitle: String - get() = customTitle.ifBlank { title } - - val isTop: Boolean - get() = id == ID_ALL_FEEDS - - val isTag: Boolean - get() = id < 1 && tag.isNotEmpty() - - override operator fun compareTo(other: FeedUnreadCount): Int { - return when { - // Top tag is always at the top (implies empty tags) - isTop -> -1 - other.isTop -> 1 - // Feeds with no tags are always last - isTag && !other.isTag && other.tag.isEmpty() -> -1 - !isTag && other.isTag && tag.isEmpty() -> 1 - !isTag && !other.isTag && tag.isNotEmpty() && other.tag.isEmpty() -> -1 - !isTag && !other.isTag && tag.isEmpty() && other.tag.isNotEmpty() -> 1 - // Feeds with identical tags compare by title - tag == other.tag -> displayTitle.compareTo(other.displayTitle, ignoreCase = true) - // In other cases it's just a matter of comparing tags - else -> tag.compareTo(other.tag, ignoreCase = true) - } - } - - override fun equals(other: Any?): Boolean { - return when (other) { - null -> false - is FeedUnreadCount -> { - // val f = other as FeedWrapper? - if (isTag && other.isTag) { - // Compare tags - tag == other.tag - } else { - // Compare items - !isTag && !other.isTag && id == other.id - } - } - else -> false - } - } - - override fun hashCode(): Int { - return if (isTag) { - // Tag - tag.hashCode() - } else { - // Item - id.hashCode() - } - } } diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feed/FeedScreen.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feed/FeedScreen.kt index d85a40551..be8c90c49 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feed/FeedScreen.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feed/FeedScreen.kt @@ -174,6 +174,7 @@ fun FeedScreen( val toastMaker: ToastMaker by instance() val viewState: FeedArticleScreenViewState by viewModel.viewState.collectAsStateWithLifecycle() val pagedFeedItems = viewModel.currentFeedListItems.collectAsLazyPagingItems() + val pagedNavDrawerItems = viewModel.pagedNavDrawerItems.collectAsLazyPagingItems() val di = LocalDI.current val savedArticleExporter = @@ -248,24 +249,26 @@ fun FeedScreen( // Below is a workaround. More info: https://issuetracker.google.com/issues/177245496. val workaroundNavDrawerListState = rememberLazyListState() + val navDrawerListStateToUse by remember { + derivedStateOf { + when (pagedNavDrawerItems.itemCount) { + 0 -> workaroundNavDrawerListState + else -> navDrawerListState + } + } + } + ScreenWithNavDrawer( - feedsAndTags = ImmutableHolder(viewState.drawerItemsWithUnreadCounts), - expandedTags = ImmutableHolder(viewState.expandedTags), - unreadBookmarksCount = viewState.unreadBookmarksCount, + feedsAndTags = pagedNavDrawerItems, onToggleTagExpansion = { tag -> viewModel.toggleTagExpansion(tag) }, onDrawerItemSelected = { feedId, tag -> FeedDestination.navigate(navController, feedId = feedId, tag = tag) - FeedDestination.navigate(navController, feedId = feedId, tag = tag) }, focusRequester = focusNavDrawer, drawerState = drawerState, - navDrawerListState = - when (viewState.drawerItemsWithUnreadCounts.size) { - 0 -> workaroundNavDrawerListState - else -> navDrawerListState - }, + navDrawerListState = navDrawerListStateToUse, ) { FeedScreen( viewState = viewState, @@ -1287,8 +1290,9 @@ fun FeedListContent( onItemClick(previewItem.id) } - if (viewState.feedItemStyle != FeedItemStyle.CARD - && viewState.feedItemStyle != FeedItemStyle.COMPACT_CARD) { + if (viewState.feedItemStyle != FeedItemStyle.CARD && + viewState.feedItemStyle != FeedItemStyle.COMPACT_CARD + ) { if (itemIndex < pagedFeedItems.itemCount - 1) { Divider( modifier = diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/FeedArticleViewModel.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/FeedArticleViewModel.kt index 3f550d767..a70aafeb2 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/FeedArticleViewModel.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/FeedArticleViewModel.kt @@ -25,6 +25,7 @@ import com.nononsenseapps.feeder.db.room.FeedItemCursor import com.nononsenseapps.feeder.db.room.FeedItemForFetching import com.nononsenseapps.feeder.db.room.FeedTitle import com.nononsenseapps.feeder.db.room.ID_UNSET +import com.nononsenseapps.feeder.model.FeedUnreadCount import com.nononsenseapps.feeder.model.FullTextParser import com.nononsenseapps.feeder.model.LocaleOverride import com.nononsenseapps.feeder.model.NoBody @@ -37,7 +38,6 @@ import com.nononsenseapps.feeder.model.UnsupportedContentType import com.nononsenseapps.feeder.model.workmanager.requestFeedSync import com.nononsenseapps.feeder.ui.compose.feed.FeedListItem import com.nononsenseapps.feeder.ui.compose.feed.FeedOrTag -import com.nononsenseapps.feeder.ui.compose.navdrawer.DrawerItemWithUnreadCount import com.nononsenseapps.feeder.ui.compose.text.htmlToAnnotatedString import com.nononsenseapps.feeder.util.Either import com.nononsenseapps.feeder.util.FilePathProvider @@ -76,6 +76,10 @@ class FeedArticleViewModel( repository.getCurrentFeedListItems() .cachedIn(viewModelScope) + val pagedNavDrawerItems: Flow> = + repository.getPagedNavDrawerItems() + .cachedIn(viewModelScope) + private val visibleFeedItemCount: StateFlow = repository.getCurrentFeedListVisibleItemCount() .stateIn( @@ -101,6 +105,14 @@ class FeedArticleViewModel( emptyList(), ) + private val expandedTags: StateFlow> = + repository.expandedTags + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + emptySet(), + ) + fun deleteFeeds(feedIds: List) = applicationCoroutineScope.launch { repository.deleteFeeds(feedIds) @@ -275,9 +287,8 @@ class FeedArticleViewModel( repository.showThumbnails, repository.currentTheme, repository.currentlySyncingLatestTimestamp, - repository.drawerItemsWithUnreadCounts, repository.feedItemStyle, - repository.expandedTags, + expandedTags, toolbarVisible, visibleFeedItemCount, screenTitleForCurrentFeedOrTag, @@ -294,7 +305,6 @@ class FeedArticleViewModel( textToDisplayTrigger, repository.useDetectLanguage, ttsStateHolder.availableLanguages, - repository.getUnreadBookmarksCount, repository.isMarkAsReadOnScroll, repository.maxLines, filterMenuVisible, @@ -302,13 +312,13 @@ class FeedArticleViewModel( repository.showOnlyTitle, repository.showReadingTime, ) { params: Array -> - val article = params[16] as Article + val article = params[15] as Article - val ttsState = params[17] as PlaybackStatus + val ttsState = params[16] as PlaybackStatus - val haveVisibleFeedItems = (params[8] as Int) > 0 + val haveVisibleFeedItems = (params[7] as Int) > 0 - val currentFeedOrTag = params[15] as FeedOrTag + val currentFeedOrTag = params[14] as FeedOrTag val textToDisplay = getTextToDisplayFor(article.id) @@ -318,18 +328,17 @@ class FeedArticleViewModel( showThumbnails = params[1] as Boolean, currentTheme = params[2] as ThemeOptions, latestSyncTimestamp = params[3] as Instant, - drawerItemsWithUnreadCounts = params[4] as List, - feedItemStyle = params[5] as FeedItemStyle, - expandedTags = params[6] as Set, - showToolbarMenu = params[7] as Boolean, + feedItemStyle = params[4] as FeedItemStyle, + expandedTags = params[5] as Set, + showToolbarMenu = params[6] as Boolean, haveVisibleFeedItems = haveVisibleFeedItems, - feedScreenTitle = params[9] as ScreenTitle, - showEditDialog = params[10] as Boolean, - showDeleteDialog = params[11] as Boolean, - visibleFeeds = params[12] as List, + feedScreenTitle = params[8] as ScreenTitle, + showEditDialog = params[9] as Boolean, + showDeleteDialog = params[10] as Boolean, + visibleFeeds = params[11] as List, articleFeedUrl = article.feedUrl, articleFeedId = article.feedId, - linkOpener = params[14] as LinkOpener, + linkOpener = params[13] as LinkOpener, pubDate = article.pubDate, author = article.author, enclosure = article.enclosure, @@ -341,18 +350,17 @@ class FeedArticleViewModel( isTTSPlaying = ttsState == PlaybackStatus.PLAYING, isBottomBarVisible = ttsState != PlaybackStatus.STOPPED, articleId = article.id, - isArticleOpen = params[13] as Boolean, - swipeAsRead = params[18] as SwipeAsRead, + isArticleOpen = params[12] as Boolean, + swipeAsRead = params[17] as SwipeAsRead, isBookmarked = article.bookmarked, - useDetectLanguage = params[20] as Boolean, - ttsLanguages = params[21] as List, - unreadBookmarksCount = params[22] as Int, - markAsReadOnScroll = params[23] as Boolean, - maxLines = params[24] as Int, - showFilterMenu = params[25] as Boolean, - filter = params[26] as FeedListFilter, - showOnlyTitle = params[27] as Boolean, - showReadingTime = params[28] as Boolean, + useDetectLanguage = params[19] as Boolean, + ttsLanguages = params[20] as List, + markAsReadOnScroll = params[21] as Boolean, + maxLines = params[22] as Int, + showFilterMenu = params[23] as Boolean, + filter = params[24] as FeedListFilter, + showOnlyTitle = params[25] as Boolean, + showReadingTime = params[26] as Boolean, wordCount = when (textToDisplay) { TextToDisplay.DEFAULT -> article.wordCount @@ -522,8 +530,6 @@ interface FeedScreenViewState { val latestSyncTimestamp: Instant val feedScreenTitle: ScreenTitle val visibleFeeds: List - val drawerItemsWithUnreadCounts: List - val unreadBookmarksCount: Int val feedItemStyle: FeedItemStyle val expandedTags: Set val isBottomBarVisible: Boolean @@ -635,8 +641,6 @@ data class FeedArticleScreenViewState( // Defaults to empty string to avoid rendering until loading complete override val feedScreenTitle: ScreenTitle = ScreenTitle("", FeedType.FEED), override val visibleFeeds: List = emptyList(), - override val drawerItemsWithUnreadCounts: List = emptyList(), - override val unreadBookmarksCount: Int = 0, override val feedItemStyle: FeedItemStyle = FeedItemStyle.CARD, override val expandedTags: Set = emptySet(), override val isBottomBarVisible: Boolean = false, diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/navdrawer/NavDrawer.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/navdrawer/NavDrawer.kt index 99c7eee80..9bf037a12 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/navdrawer/NavDrawer.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/navdrawer/NavDrawer.kt @@ -1,14 +1,9 @@ package com.nononsenseapps.feeder.ui.compose.navdrawer import android.util.Log -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -24,23 +19,17 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.Star import androidx.compose.material.minimumInteractiveComponentSize -import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -51,8 +40,10 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CollectionInfo import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.collectionInfo import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.semantics @@ -60,6 +51,9 @@ import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.semantics.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.itemContentType +import androidx.paging.compose.itemKey import coil.compose.AsyncImage import coil.request.ImageRequest import coil.size.Precision @@ -67,23 +61,19 @@ import coil.size.Scale import com.nononsenseapps.feeder.R import com.nononsenseapps.feeder.db.room.ID_ALL_FEEDS import com.nononsenseapps.feeder.db.room.ID_SAVED_ARTICLES +import com.nononsenseapps.feeder.db.room.ID_UNSET +import com.nononsenseapps.feeder.model.FeedUnreadCount import com.nononsenseapps.feeder.ui.compose.components.safeSemantics import com.nononsenseapps.feeder.ui.compose.material3.DismissibleDrawerSheet import com.nononsenseapps.feeder.ui.compose.material3.DismissibleNavigationDrawer import com.nononsenseapps.feeder.ui.compose.material3.DrawerState -import com.nononsenseapps.feeder.ui.compose.theme.FeederTheme -import com.nononsenseapps.feeder.ui.compose.utils.ImmutableHolder -import com.nononsenseapps.feeder.ui.compose.utils.immutableListHolderOf import com.nononsenseapps.feeder.ui.compose.utils.onKeyEventLikeEscape import kotlinx.coroutines.launch -import java.net.URL @OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class) @Composable fun ScreenWithNavDrawer( - feedsAndTags: ImmutableHolder>, - expandedTags: ImmutableHolder>, - unreadBookmarksCount: Int, + feedsAndTags: LazyPagingItems, onToggleTagExpansion: (String) -> Unit, onDrawerItemSelected: (Long, String) -> Unit, drawerState: DrawerState, @@ -117,112 +107,28 @@ fun ScreenWithNavDrawer( Modifier .focusRequester(focusRequester), feedsAndTags = feedsAndTags, - expandedTags = expandedTags, - unreadBookmarksCount = unreadBookmarksCount, onToggleTagExpansion = onToggleTagExpansion, - onItemClick = { item -> - coroutineScope.launch { - onDrawerItemSelected(item.id, item.tag) - drawerState.close() - } - }, - ) + ) { item -> + coroutineScope.launch { + onDrawerItemSelected(item.id, item.tag) + drawerState.close() + } + } } }, content = content, ) } -@ExperimentalAnimationApi -@Composable -@Preview(showBackground = true) -private fun ListOfFeedsAndTagsPreview() { - FeederTheme { - Surface { - ListOfFeedsAndTags( - feedsAndTags = - immutableListHolderOf( - DrawerTop(unreadCount = 100, totalChildren = 4), - DrawerSavedArticles(unreadCount = 5), - DrawerTag( - tag = "News tag", - unreadCount = 0, - -1111, - totalChildren = 2, - ), - DrawerFeed( - id = 1, - displayTitle = "Times", - tag = "News tag", - unreadCount = 0, - ), - DrawerFeed( - id = 2, - displayTitle = "Post", - imageUrl = URL("https://cowboyprogrammer.org/apple-touch-icon.png"), - tag = "News tag", - unreadCount = 2, - ), - DrawerTag( - tag = "Funny tag", - unreadCount = 6, - -2222, - totalChildren = 1, - ), - DrawerFeed( - id = 3, - displayTitle = "Hidden", - tag = "Funny tag", - unreadCount = 6, - ), - DrawerFeed( - id = 4, - displayTitle = "Top Dog", - unreadCount = 99, - tag = "", - ), - DrawerFeed( - id = 5, - imageUrl = URL("https://cowboyprogrammer.org/apple-touch-icon.png"), - displayTitle = "Cowboy Programmer", - unreadCount = 7, - tag = "", - ), - ), - expandedTags = - ImmutableHolder( - setOf( - "News tag", - "Funny tag", - ), - ), - unreadBookmarksCount = 1, - onToggleTagExpansion = {}, - state = rememberLazyListState(), - ) {} - } - } -} - @ExperimentalAnimationApi @Composable fun ListOfFeedsAndTags( - feedsAndTags: ImmutableHolder>, - expandedTags: ImmutableHolder>, - unreadBookmarksCount: Int, + feedsAndTags: LazyPagingItems, onToggleTagExpansion: (String) -> Unit, state: LazyListState, modifier: Modifier = Modifier, onItemClick: (FeedIdTag) -> Unit, ) { - val firstTopFeed by remember(feedsAndTags) { - derivedStateOf { - feedsAndTags.item.asSequence() - .filterIsInstance() - .filter { it.tag.isEmpty() } - .firstOrNull() - } - } LazyColumn( state = state, contentPadding = WindowInsets.systemBars.asPaddingValues(), @@ -231,105 +137,105 @@ fun ListOfFeedsAndTags( .fillMaxSize() .semantics { testTag = "feedsAndTags" + collectionInfo = CollectionInfo(feedsAndTags.itemCount, 1) }, ) { - item( - key = ID_ALL_FEEDS, - contentType = ID_ALL_FEEDS, - ) { - val item = - feedsAndTags.item.firstOrNull() ?: DrawerTop( - { stringResource(id = R.string.all_feeds) }, - 0, - 0, - ) - AllFeeds( - unreadCount = item.unreadCount, - title = stringResource(id = R.string.all_feeds), - onItemClick = { - onItemClick(item) + items( + count = feedsAndTags.itemCount, + key = + feedsAndTags.itemKey { + when (it.id) { + ID_ALL_FEEDS -> ID_ALL_FEEDS + ID_SAVED_ARTICLES -> ID_SAVED_ARTICLES + ID_UNSET -> it.tag + else -> it.id + } }, - ) - } - item( - key = ID_SAVED_ARTICLES, - contentType = ID_SAVED_ARTICLES, - ) { - SavedArticles( - unreadCount = unreadBookmarksCount, - title = stringResource(id = R.string.saved_articles), - onItemClick = { - onItemClick(DrawerSavedArticles(unreadCount = 1)) + contentType = + feedsAndTags.itemContentType { + it.contentType }, - ) - } - items( - feedsAndTags.item.drop(1), - key = { it.uiId }, - contentType = { - when (it) { - is DrawerFeed -> 1L - is DrawerTag -> it.id - is DrawerSavedArticles -> it.id - is DrawerTop -> it.id + ) { itemIndex -> + val item = feedsAndTags[itemIndex] + when (item?.contentType) { + null -> { + Placeholder() } - }, - ) { item -> - when (item) { - is DrawerTag -> { + ContentType.AllFeeds -> { + AllFeeds( + unreadCount = item.unreadCount, + title = stringResource(id = R.string.all_feeds), + onItemClick = { + onItemClick(item) + }, + ) + } + ContentType.SavedArticles -> { + SavedArticles( + unreadCount = item.unreadCount, + title = stringResource(id = R.string.saved_articles), + onItemClick = { + onItemClick(item) + }, + ) + } + ContentType.Tag -> { ExpandableTag( - expanded = item.tag in expandedTags.item, + expanded = item.expanded, onToggleExpansion = onToggleTagExpansion, unreadCount = item.unreadCount, - title = item.title(), + title = item.tag, onItemClick = { onItemClick(item) }, ) } - - is DrawerFeed -> { - when { - item.tag.isEmpty() -> { - if (item.id == firstTopFeed?.id) { - Divider( - modifier = Modifier.fillMaxWidth(), - ) - } - TopLevelFeed( - unreadCount = item.unreadCount, - title = item.title(), - imageUrl = item.imageUrl?.toString(), - onItemClick = { - onItemClick(item) - }, - ) - } - - else -> { - ChildFeed( - unreadCount = item.unreadCount, - title = item.title(), - imageUrl = item.imageUrl?.toString(), - visible = item.tag in expandedTags.item, - onItemClick = { - onItemClick(item) - }, - ) - } - } + ContentType.ChildFeed -> { + ChildFeed( + unreadCount = item.unreadCount, + title = item.displayTitle, + imageUrl = item.imageUrl?.toString(), + onItemClick = { + onItemClick(item) + }, + ) } - - is DrawerTop -> { - // Handled at top + ContentType.TopLevelFeed -> { + TopLevelFeed( + unreadCount = item.unreadCount, + title = item.displayTitle, + imageUrl = item.imageUrl?.toString(), + onItemClick = { + onItemClick(item) + }, + ) } + } + } + } +} - is DrawerSavedArticles -> { - // Handled at top +val FeedUnreadCount.contentType: ContentType + get() = + when (this.id) { + ID_UNSET -> ContentType.Tag + ID_ALL_FEEDS -> ContentType.AllFeeds + ID_SAVED_ARTICLES -> ContentType.SavedArticles + else -> { + if (this.tag.isNotEmpty()) { + ContentType.ChildFeed + } else { + ContentType.TopLevelFeed } } } - } + +enum class ContentType { + AllFeeds, + SavedArticles, + Tag, + ChildFeed, + TopLevelFeed, } @ExperimentalAnimationApi @@ -345,6 +251,7 @@ private fun ExpandableTag( val angle: Float by animateFloatAsState( targetValue = if (expanded) 0f else 180f, animationSpec = tween(), + label = "angle", ) val toggleExpandLabel = stringResource(id = R.string.toggle_tag_expansion) @@ -472,20 +379,25 @@ private fun ChildFeed( title: String = "Foo", imageUrl: String? = null, unreadCount: Int = 99, - visible: Boolean = true, onItemClick: () -> Unit = {}, ) { - AnimatedVisibility( - visible = visible, - enter = fadeIn() + expandVertically(expandFrom = Alignment.Top), - exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), + Feed( + title = title, + imageUrl = imageUrl, + unreadCount = unreadCount, + onItemClick = onItemClick, + ) +} + +@Preview(showBackground = true) +@Composable +private fun Placeholder() { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(48.dp), ) { - Feed( - title = title, - imageUrl = imageUrl, - unreadCount = unreadCount, - onItemClick = onItemClick, - ) } } diff --git a/app/src/test/java/com/nononsenseapps/feeder/archmodel/FeedStoreTest.kt b/app/src/test/java/com/nononsenseapps/feeder/archmodel/FeedStoreTest.kt index c191a6ac2..0bd71080e 100644 --- a/app/src/test/java/com/nononsenseapps/feeder/archmodel/FeedStoreTest.kt +++ b/app/src/test/java/com/nononsenseapps/feeder/archmodel/FeedStoreTest.kt @@ -3,17 +3,12 @@ package com.nononsenseapps.feeder.archmodel import com.nononsenseapps.feeder.db.room.Feed import com.nononsenseapps.feeder.db.room.FeedDao import com.nononsenseapps.feeder.db.room.FeedTitle -import com.nononsenseapps.feeder.model.FeedUnreadCount -import com.nononsenseapps.feeder.ui.compose.navdrawer.DrawerFeed -import com.nononsenseapps.feeder.ui.compose.navdrawer.DrawerTag -import com.nononsenseapps.feeder.ui.compose.navdrawer.DrawerTop import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.verify -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.toList import kotlinx.coroutines.runBlocking @@ -125,38 +120,6 @@ class FeedStoreTest : DIAware { } } - @Test - fun drawerItemsWithUnreadCounts() { - every { dao.loadFlowOfFeedsWithUnreadCounts() } returns - flow { - emit( - listOf( - FeedUnreadCount(id = 1, title = "zob", unreadCount = 3, currentlySyncing = true), - FeedUnreadCount(id = 2, title = "bob", tag = "zork", unreadCount = 4, currentlySyncing = false), - FeedUnreadCount(id = 3, title = "alice", tag = "alpha", unreadCount = 5, currentlySyncing = true), - FeedUnreadCount(id = 4, title = "argh", tag = "alpha", unreadCount = 7, currentlySyncing = false), - ), - ) - } - val drawerItems = - runBlocking { - store.drawerItemsWithUnreadCounts.toList().first() - } - - assertEquals( - listOf( - DrawerTop(unreadCount = 19, totalChildren = 4), - DrawerTag("alpha", 12, -1002, totalChildren = 2), - DrawerFeed(3, "alpha", "alice", unreadCount = 5), - DrawerFeed(4, "alpha", "argh", unreadCount = 7), - DrawerTag("zork", 4, -1001, totalChildren = 1), - DrawerFeed(2, "zork", "bob", unreadCount = 4), - DrawerFeed(1, "", "zob", unreadCount = 3), - ), - drawerItems, - ) - } - @Test fun getFeedTitles() { every { dao.getFeedTitlesWithId(5L) } returns