diff --git a/app/schemas/com.nononsenseapps.feeder.db.room.AppDatabase/32.json b/app/schemas/com.nononsenseapps.feeder.db.room.AppDatabase/32.json new file mode 100644 index 000000000..483b4f463 --- /dev/null +++ b/app/schemas/com.nononsenseapps.feeder.db.room.AppDatabase/32.json @@ -0,0 +1,755 @@ +{ + "formatVersion": 1, + "database": { + "version": 32, + "identityHash": "24356fe5b98667d57859c78ce8b20d6b", + "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)", + "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 + } + ], + "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": "imageUrl", + "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": [], + "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, '24356fe5b98667d57859c78ce8b20d6b')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/nononsenseapps/feeder/db/room/TestMigrationFrom31To32.kt b/app/src/androidTest/java/com/nononsenseapps/feeder/db/room/TestMigrationFrom31To32.kt new file mode 100644 index 000000000..811adc7f2 --- /dev/null +++ b/app/src/androidTest/java/com/nononsenseapps/feeder/db/room/TestMigrationFrom31To32.kt @@ -0,0 +1,73 @@ +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 kotlin.test.assertEquals +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 + +@RunWith(AndroidJUnit4::class) +@LargeTest +class TestMigrationFrom31To32 : 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) + VALUES(1, 'feed', 'http://url', '', '', 0, 0, 666, 0, '', 0, 0, 0, 0) + """.trimIndent(), + ) + oldDB.execSQL( + """ + INSERT INTO feed_items(id, guid, title, plain_title, plain_snippet, notified, feed_id, first_synced_time, primary_sort_time, pinned, bookmarked, fulltext_downloaded, read_time, unread, word_count, word_count_full) + VALUES(8, 'http://item1', 'title', 'ptitle', 'psnippet', 0, 1, 0, 0, 1, 0, 0, 0, 1, 5, 900) + """.trimIndent(), + ) + } + val db = testHelper.runMigrationsAndValidate( + dbName, + TO_VERSION, + true, + MigrationFrom31To32(di), + ) + + db.query( + """ + select image_from_body from feed_items + """.trimIndent(), + ).use { + assert(it.count == 1) + assert(it.moveToFirst()) + assertEquals(0, it.getInt(0)) + } + } + + companion object { + private const val FROM_VERSION = 31 + private const val TO_VERSION = 32 + } +} 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 61f385780..88adb100c 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/archmodel/Repository.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/archmodel/Repository.kt @@ -724,6 +724,8 @@ data class Article( val bookmarked: Boolean = item?.bookmarked ?: false val wordCount: Int = item?.wordCount ?: 0 val wordCountFull: Int = item?.wordCountFull ?: 0 + val image: String? = item?.imageUrl + val imageFromBody: Boolean = item?.imageFromBody ?: false } enum class TextToDisplay { 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 a420a210c..998132be9 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/db/Constants.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/db/Constants.kt @@ -17,6 +17,7 @@ const val COL_GUID = "guid" const val COL_PLAINTITLE = "plain_title" const val COL_PLAINSNIPPET = "plain_snippet" const val COL_IMAGEURL = "image_url" +const val COL_IMAGE_FROM_BODY = "image_from_body" const val COL_ENCLOSURELINK = "enclosure_link" const val COL_ENCLOSURE_TYPE = "enclosure_type" const val COL_LINK = "link" 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 5084efd92..941deb7d3 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,7 @@ private const val LOG_TAG = "FEEDER_APPDB" RemoteFeed::class, SyncDevice::class, ], - version = 31, + version = 32, ) @TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { @@ -118,12 +118,23 @@ fun getAllMigrations(di: DI) = arrayOf( MigrationFrom28To29(di), MigrationFrom29To30(di), MigrationFrom30To31(di), + MigrationFrom31To32(di), ) /* * 6 represents legacy database * 7 represents new Room database */ +class MigrationFrom31To32(override val di: DI) : Migration(31, 32), DIAware { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + alter table feed_items add column image_from_body integer not null default 0 + """.trimIndent(), + ) + } +} + class MigrationFrom30To31(override val di: DI) : Migration(30, 31), DIAware { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL( 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 d1bd8ade3..cfdfbf90b 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 @@ -16,6 +16,7 @@ import com.nononsenseapps.feeder.db.COL_FULLTEXT_DOWNLOADED import com.nononsenseapps.feeder.db.COL_GUID import com.nononsenseapps.feeder.db.COL_ID import com.nononsenseapps.feeder.db.COL_IMAGEURL +import com.nononsenseapps.feeder.db.COL_IMAGE_FROM_BODY import com.nononsenseapps.feeder.db.COL_LINK import com.nononsenseapps.feeder.db.COL_NOTIFIED import com.nononsenseapps.feeder.db.COL_PLAINSNIPPET @@ -73,6 +74,7 @@ data class FeedItem @Ignore constructor( @ColumnInfo(name = COL_PLAINTITLE) var plainTitle: String = "", @ColumnInfo(name = COL_PLAINSNIPPET) var plainSnippet: String = "", @ColumnInfo(name = COL_IMAGEURL) var imageUrl: String? = null, + @ColumnInfo(name = COL_IMAGE_FROM_BODY) var imageFromBody: Boolean = false, @ColumnInfo(name = COL_ENCLOSURELINK) var enclosureLink: String? = null, @ColumnInfo(name = COL_ENCLOSURE_TYPE) var enclosureType: String? = null, @ColumnInfo(name = COL_AUTHOR) var author: String? = null, @@ -137,25 +139,18 @@ data class FeedItem @Ignore constructor( // Make double sure no base64 images are used as thumbnails val safeImage = when { - entry.image?.startsWith("data") == true -> null + entry.image?.url?.startsWith("data") == true -> null else -> entry.image } - val absoluteImage = when { - feed.feed_url != null && safeImage != null -> { - relativeLinkIntoAbsolute(sloppyLinkToStrictURL(feed.feed_url), safeImage) - } - - else -> safeImage - } - this.guid = entryGuid entry.title?.let { this.plainTitle = it.take(MAX_TITLE_LENGTH) } @Suppress("DEPRECATION") this.title = this.plainTitle this.plainSnippet = summary - this.imageUrl = absoluteImage + this.imageUrl = safeImage?.url + this.imageFromBody = safeImage?.fromBody ?: false val firstEnclosure = entry.attachments?.firstOrNull() this.enclosureLink = firstEnclosure?.url this.enclosureType = firstEnclosure?.mime_type?.lowercase() diff --git a/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedItemWithFeed.kt b/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedItemWithFeed.kt index fe2ff822f..1ea7f60bd 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedItemWithFeed.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedItemWithFeed.kt @@ -15,6 +15,7 @@ import com.nononsenseapps.feeder.db.COL_FULLTEXT_BY_DEFAULT import com.nononsenseapps.feeder.db.COL_GUID import com.nononsenseapps.feeder.db.COL_ID import com.nononsenseapps.feeder.db.COL_IMAGEURL +import com.nononsenseapps.feeder.db.COL_IMAGE_FROM_BODY import com.nononsenseapps.feeder.db.COL_LINK import com.nononsenseapps.feeder.db.COL_PLAINSNIPPET import com.nononsenseapps.feeder.db.COL_PLAINTITLE @@ -44,7 +45,8 @@ const val feedItemColumnsWithFeed = """ $FEEDS_TABLE_NAME.$COL_FULLTEXT_BY_DEFAULT AS $COL_FULLTEXT_BY_DEFAULT, $COL_BOOKMARKED, $COL_WORD_COUNT, - $COL_WORD_COUNT_FULL + $COL_WORD_COUNT_FULL, + $COL_IMAGE_FROM_BODY """ data class FeedItemWithFeed @Ignore constructor( @@ -55,6 +57,7 @@ data class FeedItemWithFeed @Ignore constructor( @ColumnInfo(name = COL_PLAINTITLE) var plainTitle: String = "", @ColumnInfo(name = COL_PLAINSNIPPET) var plainSnippet: String = "", @ColumnInfo(name = COL_IMAGEURL) var imageUrl: String? = null, + @ColumnInfo(name = COL_IMAGE_FROM_BODY) var imageFromBody: Boolean = false, @ColumnInfo(name = COL_ENCLOSURELINK) var enclosureLink: String? = null, @ColumnInfo(name = COL_ENCLOSURE_TYPE) var enclosureType: String? = null, var author: String? = null, diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/ArticleScreen.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/ArticleScreen.kt index c33bce5dd..c78864399 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/ArticleScreen.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/ArticleScreen.kt @@ -422,6 +422,8 @@ fun ArticleContent( else -> null }, + image = viewState.image, + imageFromBody = viewState.imageFromBody, ) { // Can take a composition or two before viewstate is set to its actual values if (viewState.articleId > ID_UNSET) { 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 a8f37c96b..73cf11964 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 @@ -345,6 +345,8 @@ class FeedArticleViewModel( TextToDisplay.FAILED_NOT_HTML, -> 0 }, + image = article.image, + imageFromBody = article.imageFromBody, ) } .stateIn( @@ -534,6 +536,8 @@ interface ArticleScreenViewState { val isBookmarked: Boolean val keyHolder: ArticleItemKeyHolder val wordCount: Int + val image: String? + val imageFromBody: Boolean } interface ArticleItemKeyHolder { @@ -618,6 +622,8 @@ data class FeedArticleScreenViewState( override val filter: FeedListFilter = emptyFeedListFilter, val isArticleOpen: Boolean = false, override val wordCount: Int = 0, + override val image: String? = null, + override val imageFromBody: Boolean = false, ) : FeedScreenViewState, ArticleScreenViewState sealed class TSSError diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/ReaderView.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/ReaderView.kt index aec68d6f8..6e90a658b 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/ReaderView.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feedarticle/ReaderView.kt @@ -83,12 +83,14 @@ fun ReaderView( wordCount: Int, onEnclosureClick: () -> Unit, onFeedTitleClick: () -> Unit, + enclosure: Enclosure , + articleTitle: String , + feedTitle: String, + authorDate: String?, + image: String?, + imageFromBody: Boolean, modifier: Modifier = Modifier, articleListState: LazyListState = rememberLazyListState(), - enclosure: Enclosure = Enclosure(), - articleTitle: String = "Article title on top", - feedTitle: String = "Feed Title is here", - authorDate: String? = "2018-01-02", articleBody: LazyListScope.() -> Unit, ) { val dimens = LocalDimens.current @@ -136,16 +138,18 @@ fun ReaderView( } }, ) { - WithBidiDeterminedLayoutDirection(paragraph = articleTitle) { - val interactionSource = remember { MutableInteractionSource() } - Text( - text = articleTitle, - style = MaterialTheme.typography.headlineLarge, - modifier = Modifier - .indication(interactionSource, LocalIndication.current) - .focusableInNonTouchMode(interactionSource = interactionSource) - .width(dimens.maxReaderWidth), - ) + if (articleTitle.isNotBlank()) { + WithBidiDeterminedLayoutDirection(paragraph = articleTitle) { + val interactionSource = remember { MutableInteractionSource() } + Text( + text = articleTitle, + style = MaterialTheme.typography.headlineLarge, + modifier = Modifier + .indication(interactionSource, LocalIndication.current) + .focusableInNonTouchMode(interactionSource = interactionSource) + .width(dimens.maxReaderWidth), + ) + } } ProvideScaledText( style = MaterialTheme.typography.titleMedium.merge( @@ -195,7 +199,10 @@ fun ReaderView( contentDescription = null, ) val readTimeText = - pluralStringResource(id = R.plurals.n_minutes, count = readTimeSecs / 60) + pluralStringResource( + id = R.plurals.n_minutes, + count = readTimeSecs / 60, + ) .format( "${readTimeSecs / 60}:${readTimeSecs % 60}", ) @@ -222,42 +229,8 @@ fun ReaderView( if (enclosure.present) { item { - if (enclosure.isImage) { - BoxWithConstraints( - modifier = Modifier - .clip(RectangleShape) - .fillMaxWidth(), - ) { - WithTooltipIfNotBlank(tooltip = enclosure.name) { innerModifier -> - val imageWidth by rememberMaxImageWidth() - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(enclosure.link) - .scale(Scale.FIT) - .size(imageWidth) - .precision(Precision.INEXACT) - .build(), - contentDescription = enclosure.name, - placeholder = rememberTintedVectorPainter( - Icons.Outlined.Terrain, - ), - error = rememberTintedVectorPainter(Icons.Outlined.ErrorOutline), - contentScale = if (dimens.hasImageAspectRatioInReader) { - ContentScale.Fit - } else { - ContentScale.FillWidth - }, - modifier = innerModifier - .fillMaxWidth() - .run { - dimens.imageAspectRatioInReader?.let { ratio -> - aspectRatio(ratio) - } ?: this - }, - ) - } - } - } else { + // Image will be shown in block below + if (!enclosure.isImage) { val openLabel = if (enclosure.name.isBlank()) { stringResource(R.string.open_enclosed_media) } else { @@ -299,6 +272,45 @@ fun ReaderView( } } + if (!imageFromBody && image != null) { + item { + BoxWithConstraints( + modifier = Modifier + .clip(RectangleShape) + .fillMaxWidth(), + ) { + WithTooltipIfNotBlank(tooltip = stringResource(id = R.string.article_image)) { innerModifier -> + val imageWidth by rememberMaxImageWidth() + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(image) + .scale(Scale.FIT) + .size(imageWidth) + .precision(Precision.INEXACT) + .build(), + contentDescription = enclosure.name, + placeholder = rememberTintedVectorPainter( + Icons.Outlined.Terrain, + ), + error = rememberTintedVectorPainter(Icons.Outlined.ErrorOutline), + contentScale = if (dimens.hasImageAspectRatioInReader) { + ContentScale.Fit + } else { + ContentScale.FillWidth + }, + modifier = innerModifier + .fillMaxWidth() + .run { + dimens.imageAspectRatioInReader?.let { ratio -> + aspectRatio(ratio) + } ?: this + }, + ) + } + } + } + } + articleBody() } } @@ -314,6 +326,12 @@ fun ReaderPreview() { wordCount = 9831, onEnclosureClick = {}, onFeedTitleClick = {}, + enclosure = Enclosure(), + articleTitle = "Article title on top", + feedTitle = "Feed Title is here", + authorDate = "2018-01-02", + image = "https://cowboyprogrammer.org/images/2017/10/gimp_image_mode_index.png", + imageFromBody = false, ) {} } } diff --git a/app/src/main/java/com/nononsenseapps/feeder/util/HtmlUtils.kt b/app/src/main/java/com/nononsenseapps/feeder/util/HtmlUtils.kt index 8fa93cd91..5dfec677f 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/util/HtmlUtils.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/util/HtmlUtils.kt @@ -1,9 +1,10 @@ package com.nononsenseapps.feeder.util +import com.nononsenseapps.jsonfeed.ImageFromHTML import org.jsoup.Jsoup import org.jsoup.parser.Parser.unescapeEntities -fun findFirstImageLinkInHtml(text: String?, baseUrl: String?): String? = +fun findFirstImageLinkInHtml(text: String?, baseUrl: String?): ImageFromHTML? = if (text != null) { val doc = unescapeEntities(text, true).byteInputStream().use { Jsoup.parse(it, "UTF-8", baseUrl ?: "") @@ -14,12 +15,16 @@ fun findFirstImageLinkInHtml(text: String?, baseUrl: String?): String? = .map { // abs: will resolve relative urls against the baseurl - and non-url value will get // dropped, such as invalid values and data/base64 values - it.attr("abs:src") + ImageFromHTML( + url = it.attr("abs:src"), + width = it.attr("width").toIntOrNull(), + height = it.attr("height").toIntOrNull(), + ) } .firstOrNull { - it.isNotBlank() && - !it.contains("twitter_icon", ignoreCase = true) && - !it.contains("facebook_icon", ignoreCase = true) + it.url.isNotBlank() && + !it.url.contains("twitter_icon", ignoreCase = true) && + !it.url.contains("facebook_icon", ignoreCase = true) } } else { null diff --git a/app/src/main/java/com/nononsenseapps/feeder/util/RomeExtensions.kt b/app/src/main/java/com/nononsenseapps/feeder/util/RomeExtensions.kt index afcc5024d..34f590d0c 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/util/RomeExtensions.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/util/RomeExtensions.kt @@ -4,8 +4,11 @@ import android.util.Log import com.nononsenseapps.feeder.ui.text.HtmlToPlainTextConverter import com.nononsenseapps.jsonfeed.Attachment import com.nononsenseapps.jsonfeed.Author +import com.nononsenseapps.jsonfeed.EnclosureImage import com.nononsenseapps.jsonfeed.Feed import com.nononsenseapps.jsonfeed.Item +import com.nononsenseapps.jsonfeed.MediaImage +import com.nononsenseapps.jsonfeed.ThumbnailImage import com.rometools.modules.mediarss.MediaEntryModule import com.rometools.modules.mediarss.MediaModule import com.rometools.modules.mediarss.types.MediaContent @@ -70,7 +73,7 @@ fun SyndEntry.asItem(baseUrl: URL, feedAuthor: Author? = null): Item { // Base64 encoded images can be quite large - and crash database cursors val image = thumbnail(baseUrl)?.let { img -> when { - img.startsWith("data:") -> null + img.url.startsWith("data:") -> null else -> img } } @@ -221,15 +224,15 @@ fun SyndEntry.mediaDescription(): String? { /** * Returns an absolute link, or null */ -fun SyndEntry.thumbnail(feedBaseUrl: URL): String? { +fun SyndEntry.thumbnail(feedBaseUrl: URL): ThumbnailImage? { val media = this.getModule(MediaModule.URI) as MediaEntryModule? val thumbnailCandidates = sequence { - media?.findThumbnailCandidates()?.let { + media?.findThumbnailCandidates(feedBaseUrl)?.let { yieldAll(it) } enclosures?.asSequence() - ?.mapNotNull { it.findThumbnailCandidate() } + ?.mapNotNull { it.findThumbnailCandidate(feedBaseUrl) } ?.let { yieldAll(it) } @@ -238,7 +241,7 @@ fun SyndEntry.thumbnail(feedBaseUrl: URL): String? { val thumbnail = thumbnailCandidates.maxByOrNull { it.width ?: -1 } return when { - thumbnail != null -> relativeLinkIntoAbsolute(feedBaseUrl, thumbnail.url) + thumbnail != null -> thumbnail else -> { // Now we are resolving against original, not the feed val baseUrl: String = this.linkToHtml(feedBaseUrl) ?: feedBaseUrl.toString() @@ -247,59 +250,53 @@ fun SyndEntry.thumbnail(feedBaseUrl: URL): String? { } } -data class ThumbnailCandidate( - val width: Int?, - val height: Int?, - val url: String, -) - -private fun MediaEntryModule.findThumbnailCandidates(): Sequence { +private fun MediaEntryModule.findThumbnailCandidates(feedBaseUrl: URL): Sequence { return sequence { mediaContents?.forEach { mediaContent -> - yieldAll(mediaContent.findThumbnailCandidates()) + yieldAll(mediaContent.findThumbnailCandidates(feedBaseUrl)) } metadata?.thumbnail?.let { thumbnails -> yieldAll( - thumbnails.mapNotNull { it.findThumbnailCandidate() }, + thumbnails.mapNotNull { it.findThumbnailCandidate(feedBaseUrl) }, ) } mediaGroups?.forEach { mediaGroup -> - yieldAll(mediaGroup.findThumbnailCandidates()) + yieldAll(mediaGroup.findThumbnailCandidates(feedBaseUrl)) } } } -private fun SyndEnclosure.findThumbnailCandidate(): ThumbnailCandidate? { +private fun SyndEnclosure.findThumbnailCandidate(feedBaseUrl: URL): ThumbnailImage? { if (type?.startsWith("image/") == true) { url?.let { url -> - return ThumbnailCandidate(width = null, height = null, url = url) + return EnclosureImage(width = null, height = null, url = relativeLinkIntoAbsolute(feedBaseUrl, url)) } } return null } -private fun MediaGroup.findThumbnailCandidates(): Sequence = sequence { +private fun MediaGroup.findThumbnailCandidates(feedBaseUrl: URL): Sequence = sequence { metadata.thumbnail?.forEach { thumbnail -> - thumbnail.findThumbnailCandidate()?.let { thumbnailCandidate -> + thumbnail.findThumbnailCandidate(feedBaseUrl)?.let { thumbnailCandidate -> yield(thumbnailCandidate) } } } -private fun Thumbnail.findThumbnailCandidate(): ThumbnailCandidate? { +private fun Thumbnail.findThumbnailCandidate(feedBaseUrl: URL): ThumbnailImage? { return url?.let { url -> - ThumbnailCandidate( + MediaImage( width = width, height = height, - url = url.toString(), + url = relativeLinkIntoAbsolute(feedBaseUrl, url.toString()), ) } } -private fun MediaContent.findThumbnailCandidates(): Sequence = +private fun MediaContent.findThumbnailCandidates(feedBaseUrl: URL): Sequence = sequence { metadata?.thumbnail?.forEach { thumbnail -> - thumbnail.findThumbnailCandidate()?.let { thumbnailCandidate -> + thumbnail.findThumbnailCandidate(feedBaseUrl)?.let { thumbnailCandidate -> yield(thumbnailCandidate) } } @@ -307,10 +304,10 @@ private fun MediaContent.findThumbnailCandidates(): Sequence if (isImage()) { reference?.let { ref -> yield( - ThumbnailCandidate( + MediaImage( width = width, height = height, - url = ref.toString(), + url = relativeLinkIntoAbsolute(feedBaseUrl, ref.toString()), ), ) } diff --git a/app/src/main/java/com/nononsenseapps/jsonfeed/JsonFeedParser.kt b/app/src/main/java/com/nononsenseapps/jsonfeed/JsonFeedParser.kt index 723928364..48436dcb0 100644 --- a/app/src/main/java/com/nononsenseapps/jsonfeed/JsonFeedParser.kt +++ b/app/src/main/java/com/nononsenseapps/jsonfeed/JsonFeedParser.kt @@ -157,7 +157,7 @@ data class Item( val content_html: String? = null, val content_text: String? = null, val summary: String? = null, - val image: String? = null, + val image: ThumbnailImage? = null, val banner_image: String? = null, val date_published: String? = null, val date_modified: String? = null, diff --git a/app/src/main/java/com/nononsenseapps/jsonfeed/ThumbnailImage.kt b/app/src/main/java/com/nononsenseapps/jsonfeed/ThumbnailImage.kt new file mode 100644 index 000000000..e6e3f1e4c --- /dev/null +++ b/app/src/main/java/com/nononsenseapps/jsonfeed/ThumbnailImage.kt @@ -0,0 +1,13 @@ +package com.nononsenseapps.jsonfeed + + +/** + * URL should be absolute at all times + */ +sealed class ThumbnailImage(val url: String, val width: Int?, val height: Int?, val fromBody: Boolean) + +class ImageFromHTML(url: String, width: Int?, height: Int?) : ThumbnailImage(url, width, height, fromBody = true) + +class EnclosureImage(url: String, width: Int?, height: Int?) : ThumbnailImage(url, width, height, fromBody = false) + +class MediaImage(url: String, width: Int?, height: Int?) : ThumbnailImage(url, width, height, fromBody = false)