diff --git a/app/schemas/com.nononsenseapps.feeder.db.room.AppDatabase/35.json b/app/schemas/com.nononsenseapps.feeder.db.room.AppDatabase/35.json new file mode 100644 index 0000000000..8ec91da71a --- /dev/null +++ b/app/schemas/com.nononsenseapps.feeder.db.room.AppDatabase/35.json @@ -0,0 +1,781 @@ +{ + "formatVersion": 1, + "database": { + "version": 35, + "identityHash": "99115986ce9f88dc9d8e37f8c60c1a21", + "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, `block_time` INTEGER, 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 + }, + { + "fieldPath": "blockTime", + "columnName": "block_time", + "affinity": "INTEGER", + "notNull": false + } + ], + "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": "index_feed_items_block_time", + "unique": false, + "columnNames": [ + "block_time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_items_block_time` ON `${TABLE_NAME}` (`block_time`)" + }, + { + "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 block_time is null\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, '99115986ce9f88dc9d8e37f8c60c1a21')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/nononsenseapps/feeder/db/room/TestMigrationFrom34To35.kt b/app/src/androidTest/java/com/nononsenseapps/feeder/db/room/TestMigrationFrom34To35.kt new file mode 100644 index 0000000000..931e7b53b4 --- /dev/null +++ b/app/src/androidTest/java/com/nononsenseapps/feeder/db/room/TestMigrationFrom34To35.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 TestMigrationFrom34To35 : 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, + MigrationFrom34To35(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 = 34 + private const val TO_VERSION = 35 + } +} diff --git a/app/src/main/java/com/nononsenseapps/feeder/archmodel/FeedItemStore.kt b/app/src/main/java/com/nononsenseapps/feeder/archmodel/FeedItemStore.kt index d54a3f217d..6a94957dd9 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/archmodel/FeedItemStore.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/archmodel/FeedItemStore.kt @@ -6,6 +6,7 @@ import androidx.paging.PagingData import androidx.paging.map import androidx.sqlite.db.SimpleSQLiteQuery import com.nononsenseapps.feeder.db.room.AppDatabase +import com.nononsenseapps.feeder.db.room.BlocklistDao import com.nononsenseapps.feeder.db.room.FeedItem import com.nononsenseapps.feeder.db.room.FeedItemCursor import com.nononsenseapps.feeder.db.room.FeedItemDao @@ -38,8 +39,16 @@ import java.util.Locale class FeedItemStore(override val di: DI) : DIAware { private val dao: FeedItemDao by instance() + private val blocklistDao: BlocklistDao by instance() private val appDatabase: AppDatabase by instance() + suspend fun setBlockStatusForNewInFeed( + feedId: Long, + blockTime: Instant, + ) { + blocklistDao.setItemBlockStatusForNewInFeed(feedId, blockTime) + } + fun getFeedItemCountRaw( feedId: Long, tag: String, @@ -109,7 +118,7 @@ class FeedItemStore(override val di: DI) : DIAware { val onlySavedArticles = feedId == ID_SAVED_ARTICLES // Always blocklist - append("NOT EXISTS (SELECT 1 FROM blocklist WHERE lower(feed_items.plain_title) GLOB blocklist.glob_pattern)\n") + append("block_time is null\n") // List filter if (!onlySavedArticles) { append("AND (\n") 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 1a175fb38b..a23200acb5 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/archmodel/Repository.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/archmodel/Repository.kt @@ -26,6 +26,7 @@ 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.BlockListWorker import com.nononsenseapps.feeder.model.workmanager.SyncServiceSendReadWorker import com.nononsenseapps.feeder.model.workmanager.requestFeedSync import com.nononsenseapps.feeder.sync.DeviceListResponse @@ -187,9 +188,22 @@ class Repository(override val di: DI) : DIAware { val blockList: Flow> = settingsStore.blockListPreference - suspend fun addBlocklistPattern(pattern: String) = settingsStore.addBlocklistPattern(pattern) + suspend fun addBlocklistPattern(pattern: String) { + settingsStore.addBlocklistPattern(pattern) + scheduleBlockListUpdate(0) + } + + suspend fun removeBlocklistPattern(pattern: String) { + settingsStore.removeBlocklistPattern(pattern) + scheduleBlockListUpdate(0) + } - suspend fun removeBlocklistPattern(pattern: String) = settingsStore.removeBlocklistPattern(pattern) + suspend fun setBlockStatusForNewInFeed( + feedId: Long, + blockTime: Instant, + ) { + feedItemStore.setBlockStatusForNewInFeed(feedId, blockTime) + } val currentSorting: StateFlow = settingsStore.currentSorting @@ -701,6 +715,26 @@ class Repository(override val di: DI) : DIAware { ) } + fun scheduleBlockListUpdate(delaySeconds: Long) { + logDebug(LOG_TAG, "Scheduling work") + + val constraints = + Constraints.Builder() + + val workRequest = + OneTimeWorkRequestBuilder() + .addTag("feeder") + .keepResultsForAtLeast(5, TimeUnit.MINUTES) + .setConstraints(constraints.build()) + .setInitialDelay(delaySeconds, TimeUnit.SECONDS) + + workManager.enqueueUniqueWork( + BlockListWorker.UNIQUE_BLOCKLIST_NAME, + ExistingWorkPolicy.REPLACE, + workRequest.build(), + ) + } + suspend fun loadFeedIfStale( feedId: Long, staleTime: Long, diff --git a/app/src/main/java/com/nononsenseapps/feeder/db/Constants.kt b/app/src/main/java/com/nononsenseapps/feeder/db/Constants.kt index c9f9500268..e3e510b63b 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/db/Constants.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/db/Constants.kt @@ -51,6 +51,7 @@ const val COL_SITE_FETCHED = "site_fetched" const val COL_WORD_COUNT = "word_count" const val COL_WORD_COUNT_FULL = "word_count_full" const val COL_SKIP_DUPLICATES = "skip_duplicates" +const val COL_BLOCK_TIME = "block_time" // year 5000 val FAR_FUTURE = Instant.ofEpochSecond(95635369646) 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 7f3abaa2f8..2f784569c4 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 @@ -12,6 +12,7 @@ import androidx.room.TypeConverters import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import com.nononsenseapps.feeder.FeederApplication +import com.nononsenseapps.feeder.archmodel.Repository import com.nononsenseapps.feeder.blob.blobOutputStream import com.nononsenseapps.feeder.crypto.AesCbcWithIntegrity import com.nononsenseapps.feeder.util.FilePathProvider @@ -53,7 +54,7 @@ private const val LOG_TAG = "FEEDER_APPDB" views = [ FeedsWithItemsForNavDrawer::class, ], - version = 34, + version = 35, ) @TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { @@ -132,12 +133,30 @@ fun getAllMigrations(di: DI) = MigrationFrom31To32(di), MigrationFrom32To33(di), MigrationFrom33To34(di), + MigrationFrom34To35(di), ) /* * 6 represents legacy database * 7 represents new Room database */ +class MigrationFrom34To35(override val di: DI) : Migration(34, 35), DIAware { + private val repository: Repository by instance() + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("drop view feeds_with_items_for_nav_drawer") + + database.execSQL("alter table feed_items add column block_time integer default null") + database.execSQL("create index index_feed_items_block_time on feed_items (block_time)") + + // Room schema is anal about whitespace + val sql = "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\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 block_time is null\n )\n ON feeds.id = feed_id" + database.execSQL(sql) + + repository.scheduleBlockListUpdate(0) + } +} + class MigrationFrom33To34(override val di: DI) : Migration(33, 34), DIAware { override fun migrate(database: SupportSQLiteDatabase) { // Room schema is anal about whitespace diff --git a/app/src/main/java/com/nononsenseapps/feeder/db/room/BlocklistDao.kt b/app/src/main/java/com/nononsenseapps/feeder/db/room/BlocklistDao.kt index 2cd1da0f65..afed800fe5 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/db/room/BlocklistDao.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/db/room/BlocklistDao.kt @@ -3,6 +3,7 @@ package com.nononsenseapps.feeder.db.room import androidx.room.Dao import androidx.room.Query import kotlinx.coroutines.flow.Flow +import java.time.Instant @Dao interface BlocklistDao { @@ -30,4 +31,45 @@ interface BlocklistDao { """, ) fun getGlobPatterns(): Flow> + + @Query( + """ + update feed_items + set block_time = case + when exists(select 1 from blocklist where lower(feed_items.plain_title) glob blocklist.glob_pattern) + then coalesce(block_time, :blockTime) + else null + end + """, + ) + suspend fun setItemBlockStatus(blockTime: Instant) + + @Query( + """ + update feed_items + set block_time = case + when exists(select 1 from blocklist where lower(feed_items.plain_title) glob blocklist.glob_pattern) + then :blockTime + else null + end + where block_time is null + """, + ) + suspend fun setItemBlockStatusWhereNull(blockTime: Instant) + + @Query( + """ + update feed_items + set block_time = case + when exists(select 1 from blocklist where lower(feed_items.plain_title) glob blocklist.glob_pattern) + then :blockTime + else null + end + where feed_id = :feedId and block_time is null + """, + ) + suspend fun setItemBlockStatusForNewInFeed( + feedId: Long, + blockTime: Instant, + ) } diff --git a/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedItem.kt b/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedItem.kt index a9e27d91e2..219090e8be 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedItem.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedItem.kt @@ -7,6 +7,7 @@ import androidx.room.Ignore import androidx.room.Index import androidx.room.PrimaryKey import com.nononsenseapps.feeder.db.COL_AUTHOR +import com.nononsenseapps.feeder.db.COL_BLOCK_TIME import com.nononsenseapps.feeder.db.COL_BOOKMARKED import com.nononsenseapps.feeder.db.COL_ENCLOSURELINK import com.nononsenseapps.feeder.db.COL_ENCLOSURE_TYPE @@ -47,6 +48,7 @@ private val patternWhitespace = "\\s+".toRegex() indices = [ Index(value = [COL_GUID, COL_FEEDID], unique = true), Index(value = [COL_FEEDID]), + Index(value = [COL_BLOCK_TIME]), Index( name = "idx_feed_items_cursor", value = [COL_PRIMARYSORTTIME, COL_PUBDATE, COL_ID], @@ -116,6 +118,7 @@ data class FeedItem ) var readTime: Instant? = null, @ColumnInfo(name = COL_WORD_COUNT) var wordCount: Int = 0, @ColumnInfo(name = COL_WORD_COUNT_FULL) var wordCountFull: Int = 0, + @ColumnInfo(name = COL_BLOCK_TIME) var blockTime: Instant? = null, ) : FeedItemForFetching, FeedItemCursor { constructor() : this(id = ID_UNSET) diff --git a/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedItemDao.kt b/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedItemDao.kt index 4badbdd400..0474a2e572 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedItemDao.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedItemDao.kt @@ -115,7 +115,7 @@ interface FeedItemDao { """ SELECT $COL_ID as id, $COL_TITLE as title, $COL_PLAINSNIPPET as text FROM feed_items - WHERE NOT EXISTS (SELECT 1 FROM blocklist WHERE lower(feed_items.plain_title) GLOB blocklist.glob_pattern) + WHERE block_time is null ORDER BY primary_sort_time DESC, pub_date DESC """, ) @@ -126,7 +126,7 @@ interface FeedItemDao { SELECT $COL_ID as id, $COL_TITLE as title, $COL_PLAINSNIPPET as text FROM feed_items WHERE feed_items.feed_id = :feedId - AND NOT EXISTS (SELECT 1 FROM blocklist WHERE lower(feed_items.plain_title) GLOB blocklist.glob_pattern) + AND block_time is null ORDER BY primary_sort_time DESC, pub_date DESC """, ) @@ -166,7 +166,7 @@ interface FeedItemDao { WHERE feed_id IS :feedId AND (read_time is null or read_time >= :minReadTime) AND bookmarked in (1, :bookmarked) - AND NOT EXISTS (SELECT 1 FROM blocklist WHERE lower(feed_items.plain_title) GLOB blocklist.glob_pattern) + AND block_time is null ORDER BY $FEED_ITEM_LIST_SORT_ORDER_DESC """, ) @@ -184,7 +184,7 @@ interface FeedItemDao { WHERE tag IS :tag AND (read_time is null or read_time >= :minReadTime) AND bookmarked in (1, :bookmarked) - AND NOT EXISTS (SELECT 1 FROM blocklist WHERE lower(feed_items.plain_title) GLOB blocklist.glob_pattern) + AND block_time is null ORDER BY $FEED_ITEM_LIST_SORT_ORDER_DESC """, ) @@ -201,7 +201,7 @@ interface FeedItemDao { LEFT JOIN feeds ON feed_items.feed_id = feeds.id WHERE (read_time is null or read_time >= :minReadTime) AND bookmarked in (1, :bookmarked) - AND NOT EXISTS (SELECT 1 FROM blocklist WHERE lower(feed_items.plain_title) GLOB blocklist.glob_pattern) + AND block_time is null ORDER BY $FEED_ITEM_LIST_SORT_ORDER_DESC """, ) @@ -218,7 +218,7 @@ interface FeedItemDao { WHERE feed_id IS :feedId AND (read_time is null or read_time >= :minReadTime) AND bookmarked in (1, :bookmarked) - AND NOT EXISTS (SELECT 1 FROM blocklist WHERE lower(feed_items.plain_title) GLOB blocklist.glob_pattern) + AND block_time is null ORDER BY $FEED_ITEM_LIST_SORT_ORDER_ASC """, ) @@ -236,7 +236,7 @@ interface FeedItemDao { WHERE tag IS :tag AND (read_time is null or read_time >= :minReadTime) AND bookmarked in (1, :bookmarked) - AND NOT EXISTS (SELECT 1 FROM blocklist WHERE lower(feed_items.plain_title) GLOB blocklist.glob_pattern) + AND block_time is null ORDER BY $FEED_ITEM_LIST_SORT_ORDER_ASC """, ) @@ -253,7 +253,7 @@ interface FeedItemDao { LEFT JOIN feeds ON feed_items.feed_id = feeds.id WHERE (read_time is null or read_time >= :minReadTime) AND bookmarked in (1, :bookmarked) - AND NOT EXISTS (SELECT 1 FROM blocklist WHERE lower(feed_items.plain_title) GLOB blocklist.glob_pattern) + AND block_time is null ORDER BY $FEED_ITEM_LIST_SORT_ORDER_ASC """, ) @@ -268,7 +268,7 @@ interface FeedItemDao { FROM feed_items LEFT JOIN feeds ON feed_items.feed_id = feeds.id WHERE feed_id IN (:feedIds) AND notified IS 0 AND read_time is null - AND NOT EXISTS (SELECT 1 FROM blocklist WHERE lower(feed_items.plain_title) GLOB blocklist.glob_pattern) + AND block_time is null ORDER BY $FEED_ITEM_LIST_SORT_ORDER_DESC LIMIT 20 """, @@ -403,7 +403,7 @@ interface FeedItemDao { JOIN feeds f ON feed_id = f.id WHERE f.fulltext_by_default = 1 AND fi.fulltext_downloaded <> 1 - AND NOT EXISTS (SELECT 1 FROM blocklist WHERE lower(fi.plain_title) GLOB blocklist.glob_pattern) + and block_time is null """, ) fun getFeedsItemsWithDefaultFullTextNeedingDownload(): Flow> @@ -424,7 +424,7 @@ interface FeedItemDao { WHERE (read_time is null or read_time >= :minReadTime) and bookmarked in (1, :bookmarked) - and NOT EXISTS (SELECT 1 FROM blocklist WHERE lower(fi.plain_title) GLOB blocklist.glob_pattern) + and block_time is null """, ) fun getFeedItemCount( @@ -440,7 +440,7 @@ interface FeedItemDao { WHERE f.tag IS :tag and (read_time is null or read_time >= :minReadTime) and bookmarked in (1, :bookmarked) - AND NOT EXISTS (SELECT 1 FROM blocklist WHERE lower(fi.plain_title) GLOB blocklist.glob_pattern) + and block_time is null """, ) fun getFeedItemCount( @@ -456,7 +456,7 @@ interface FeedItemDao { WHERE fi.feed_id IS :feedId and (read_time is null or read_time >= :minReadTime) and bookmarked in (1, :bookmarked) - AND NOT EXISTS (SELECT 1 FROM blocklist WHERE lower(fi.plain_title) GLOB blocklist.glob_pattern) + and block_time is null """, ) fun getFeedItemCount( @@ -471,7 +471,7 @@ interface FeedItemDao { FROM feed_items fi JOIN feeds f ON feed_id = f.id WHERE f.notify IS 1 AND fi.notified IS 0 AND fi.read_time is null - AND NOT EXISTS (SELECT 1 FROM blocklist WHERE lower(fi.plain_title) GLOB blocklist.glob_pattern) + and block_time is null """, ) fun getFeedItemsNeedingNotifying(): Flow> 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 index 9ac3731426..129964e48a 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedsWithItemsForNavDrawer.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedsWithItemsForNavDrawer.kt @@ -10,7 +10,7 @@ import androidx.room.DatabaseView 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) + where block_time is null ) ON feeds.id = feed_id """, diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/RssLocalSync.kt b/app/src/main/java/com/nononsenseapps/feeder/model/RssLocalSync.kt index 6f258a6373..228e9ae802 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/model/RssLocalSync.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/model/RssLocalSync.kt @@ -168,10 +168,11 @@ class RssLocalSync(override val di: DI) : DIAware { ) { try { // Want unique sync times. + val syncTime = Instant.now() repository.setCurrentlySyncingOn( feedId = feed.id, syncing = true, - lastSync = Instant.now(), + lastSync = syncTime, ) syncFeed( feedSql = feed, @@ -183,6 +184,8 @@ class RssLocalSync(override val di: DI) : DIAware { LOG_TAG, "Failed to sync ${feed.displayTitle}: ${feed.url} because:\n${feedParserError.description}", ) + }.onRight { + repository.setBlockStatusForNewInFeed(feedId = feed.id, blockTime = syncTime) } } catch (e: Throwable) { Log.e( diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/workmanager/BlockListWorker.kt b/app/src/main/java/com/nononsenseapps/feeder/model/workmanager/BlockListWorker.kt new file mode 100644 index 0000000000..dbbcd931d2 --- /dev/null +++ b/app/src/main/java/com/nononsenseapps/feeder/model/workmanager/BlockListWorker.kt @@ -0,0 +1,50 @@ +package com.nononsenseapps.feeder.model.workmanager + +import android.content.Context +import androidx.core.app.NotificationManagerCompat +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.WorkerParameters +import com.nononsenseapps.feeder.db.room.BlocklistDao +import com.nononsenseapps.feeder.db.room.ID_UNSET +import com.nononsenseapps.feeder.ui.ARG_FEED_ID +import com.nononsenseapps.feeder.ui.ARG_ONLY_NEW +import com.nononsenseapps.feeder.util.logDebug +import org.kodein.di.DI +import org.kodein.di.DIAware +import org.kodein.di.android.closestDI +import org.kodein.di.instance +import java.time.Instant + +class BlockListWorker(val context: Context, workerParams: WorkerParameters) : + CoroutineWorker(context, workerParams), DIAware { + override val di: DI by closestDI(context) + + private val notificationManager: NotificationManagerCompat by instance() + private val blocklistDao: BlocklistDao by instance() + + override suspend fun getForegroundInfo(): ForegroundInfo { + return createForegroundInfo(context, notificationManager) + } + + override suspend fun doWork(): Result { + logDebug(LOG_TAG, "Doing work...") + + val onlyNew = inputData.getBoolean(ARG_ONLY_NEW, false) + val feedId = inputData.getLong(ARG_FEED_ID, ID_UNSET) + + when { + feedId != ID_UNSET -> blocklistDao.setItemBlockStatusForNewInFeed(feedId, Instant.now()) + onlyNew -> blocklistDao.setItemBlockStatusWhereNull(Instant.now()) + else -> blocklistDao.setItemBlockStatus(Instant.now()) + } + + logDebug(LOG_TAG, "Work done!") + return Result.success() + } + + companion object { + const val LOG_TAG = "FEEDER_BLOCKLIST" + const val UNIQUE_BLOCKLIST_NAME = "feeder_blocklist_worker" + } +} diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/Constants.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/Constants.kt index 0dbb0d92ea..6fcaf553de 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/Constants.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/Constants.kt @@ -13,3 +13,4 @@ const val ARG_FEED_TAG = "feed_tag" const val ARG_FEED_FULL_TEXT_BY_DEFAULT = "feed_full_text_by_default" const val ARG_FEED_OPEN_ARTICLES_WITH = "feed_open_articles_with" const val ARG_URL = "url" +const val ARG_ONLY_NEW = "only_new"