diff --git a/app/schemas/com.nononsenseapps.feeder.db.room.AppDatabase/29.json b/app/schemas/com.nononsenseapps.feeder.db.room.AppDatabase/29.json new file mode 100644 index 000000000..6e5acbffe --- /dev/null +++ b/app/schemas/com.nononsenseapps.feeder.db.room.AppDatabase/29.json @@ -0,0 +1,737 @@ +{ + "formatVersion": 1, + "database": { + "version": 29, + "identityHash": "cf20b4f82806500e920fde90e8d78cc7", + "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, `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, 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": "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 + } + ], + "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, 'cf20b4f82806500e920fde90e8d78cc7')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/nononsenseapps/feeder/db/room/TestMigrationFrom28To29.kt b/app/src/androidTest/java/com/nononsenseapps/feeder/db/room/TestMigrationFrom28To29.kt new file mode 100644 index 000000000..900e54575 --- /dev/null +++ b/app/src/androidTest/java/com/nononsenseapps/feeder/db/room/TestMigrationFrom28To29.kt @@ -0,0 +1,74 @@ +package com.nononsenseapps.feeder.db.room + +import androidx.core.database.getStringOrNull +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.assertNull +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 TestMigrationFrom28To29 : 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) + VALUES(8, 'http://item1', 'title', 'ptitle', 'psnippet', 0, 1, 0, 0, 1, 0, 0, 0, 1) + """.trimIndent(), + ) + } + val db = testHelper.runMigrationsAndValidate( + dbName, + TO_VERSION, + true, + MigrationFrom28To29(di), + ) + + db.query( + """ + SELECT enclosure_type FROM feed_items + """.trimIndent(), + ).use { + assert(it.count == 1) + assert(it.moveToFirst()) + assertNull(it.getStringOrNull(0)) + } + } + + companion object { + private const val FROM_VERSION = 28 + private const val TO_VERSION = 29 + } +} 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 5c7cc8ab0..1c11cee9c 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/archmodel/Repository.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/archmodel/Repository.kt @@ -684,8 +684,12 @@ data class Enclosure( val present: Boolean = false, val link: String = "", val name: String = "", + val type: String = "", ) +val Enclosure.isImage: Boolean + get() = type.startsWith("image/") + @Immutable data class Article( val item: FeedItemWithFeed?, @@ -699,6 +703,7 @@ data class Article( present = true, link = link, name = item.enclosureFilename ?: "", + type = item.enclosureType ?: "", ) } ?: Enclosure( present = false, 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 b5cdd6feb..decbf6c11 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/db/Constants.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/db/Constants.kt @@ -18,6 +18,7 @@ const val COL_PLAINTITLE = "plain_title" const val COL_PLAINSNIPPET = "plain_snippet" const val COL_IMAGEURL = "image_url" const val COL_ENCLOSURELINK = "enclosure_link" +const val COL_ENCLOSURE_TYPE = "enclosure_type" const val COL_LINK = "link" const val COL_AUTHOR = "author" const val COL_PUBDATE = "pub_date" 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 956fa188a..f7db4dd7f 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 = 28, + version = 29, ) @TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { @@ -115,12 +115,23 @@ fun getAllMigrations(di: DI) = arrayOf( MigrationFrom25To26(di), MigrationFrom26To27(di), MigrationFrom27To28(di), + MigrationFrom28To29(di), ) /* * 6 represents legacy database * 7 represents new Room database */ +class MigrationFrom28To29(override val di: DI) : Migration(28, 29), DIAware { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + alter table feed_items add column enclosure_type text + """.trimIndent(), + ) + } +} + class MigrationFrom27To28(override val di: DI) : Migration(27, 28), 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 33e1d93d7..a8364c301 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 @@ -9,6 +9,7 @@ import androidx.room.PrimaryKey import com.nononsenseapps.feeder.db.COL_AUTHOR import com.nononsenseapps.feeder.db.COL_BOOKMARKED import com.nononsenseapps.feeder.db.COL_ENCLOSURELINK +import com.nononsenseapps.feeder.db.COL_ENCLOSURE_TYPE import com.nononsenseapps.feeder.db.COL_FEEDID import com.nononsenseapps.feeder.db.COL_FIRSTSYNCEDTIME import com.nononsenseapps.feeder.db.COL_FULLTEXT_DOWNLOADED @@ -69,6 +70,7 @@ data class FeedItem @Ignore constructor( @ColumnInfo(name = COL_PLAINSNIPPET) var plainSnippet: String = "", @ColumnInfo(name = COL_IMAGEURL) var imageUrl: String? = null, @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, @ColumnInfo(name = COL_PUBDATE, typeAffinity = ColumnInfo.TEXT) override var pubDate: ZonedDateTime? = null, @ColumnInfo(name = COL_LINK) override var link: String? = null, @@ -125,7 +127,10 @@ data class FeedItem @Ignore constructor( this.plainSnippet = summary this.imageUrl = absoluteImage - this.enclosureLink = entry.attachments?.firstOrNull()?.url + val firstEnclosure = entry.attachments?.firstOrNull() + this.enclosureLink = firstEnclosure?.url + this.enclosureType = firstEnclosure?.mime_type?.lowercase() + this.author = entry.author?.name ?: feed.author?.name this.link = entry.url 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 cd4f4ad6b..5ce5db253 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 @@ -6,6 +6,7 @@ import com.nononsenseapps.feeder.db.COL_AUTHOR import com.nononsenseapps.feeder.db.COL_BOOKMARKED import com.nononsenseapps.feeder.db.COL_CUSTOM_TITLE import com.nononsenseapps.feeder.db.COL_ENCLOSURELINK +import com.nononsenseapps.feeder.db.COL_ENCLOSURE_TYPE import com.nononsenseapps.feeder.db.COL_FEEDCUSTOMTITLE import com.nononsenseapps.feeder.db.COL_FEEDID import com.nononsenseapps.feeder.db.COL_FEEDTITLE @@ -33,7 +34,7 @@ import java.time.ZonedDateTime const val feedItemColumnsWithFeed = """ $FEED_ITEMS_TABLE_NAME.$COL_ID AS $COL_ID, $COL_GUID, $FEED_ITEMS_TABLE_NAME.$COL_TITLE AS $COL_TITLE, - $COL_PLAINTITLE, $COL_PLAINSNIPPET, $FEED_ITEMS_TABLE_NAME.$COL_IMAGEURL, $COL_ENCLOSURELINK, + $COL_PLAINTITLE, $COL_PLAINSNIPPET, $FEED_ITEMS_TABLE_NAME.$COL_IMAGEURL, $COL_ENCLOSURELINK, $COL_ENCLOSURE_TYPE, $COL_AUTHOR, $COL_PUBDATE, $COL_LINK, $COL_READ_TIME, $FEEDS_TABLE_NAME.$COL_TAG AS $COL_TAG, $FEEDS_TABLE_NAME.$COL_ID AS $COL_FEEDID, $FEEDS_TABLE_NAME.$COL_TITLE AS $COL_FEEDTITLE, $FEEDS_TABLE_NAME.$COL_CUSTOM_TITLE AS $COL_FEEDCUSTOMTITLE, @@ -51,6 +52,7 @@ data class FeedItemWithFeed @Ignore constructor( @ColumnInfo(name = COL_PLAINSNIPPET) var plainSnippet: String = "", @ColumnInfo(name = COL_IMAGEURL) var imageUrl: String? = null, @ColumnInfo(name = COL_ENCLOSURELINK) var enclosureLink: String? = null, + @ColumnInfo(name = COL_ENCLOSURE_TYPE) var enclosureType: String? = null, var author: String? = null, @ColumnInfo(name = COL_PUBDATE) var pubDate: ZonedDateTime? = null, override var link: String? = null, 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 9b6edf206..024de331b 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 @@ -8,9 +8,11 @@ import androidx.compose.foundation.focusGroup import androidx.compose.foundation.indication import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.width @@ -21,13 +23,21 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.ContentAlpha import androidx.compose.material.LocalContentAlpha +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material.icons.outlined.Terrain import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.clearAndSetSemantics @@ -35,11 +45,20 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import coil.size.Precision +import coil.size.Scale import com.nononsenseapps.feeder.R import com.nononsenseapps.feeder.archmodel.Enclosure +import com.nononsenseapps.feeder.archmodel.isImage +import com.nononsenseapps.feeder.ui.compose.coil.rememberTintedVectorPainter import com.nononsenseapps.feeder.ui.compose.text.WithBidiDeterminedLayoutDirection +import com.nononsenseapps.feeder.ui.compose.text.WithTooltipIfNotBlank +import com.nononsenseapps.feeder.ui.compose.text.rememberMaxImageWidth import com.nononsenseapps.feeder.ui.compose.theme.LinkTextStyle import com.nononsenseapps.feeder.ui.compose.theme.LocalDimens +import com.nononsenseapps.feeder.ui.compose.theme.hasImageAspectRatioInReader import com.nononsenseapps.feeder.ui.compose.utils.ProvideScaledText import com.nononsenseapps.feeder.ui.compose.utils.ScreenType import com.nononsenseapps.feeder.ui.compose.utils.focusableInNonTouchMode @@ -158,15 +177,47 @@ fun ReaderView( if (enclosure.present) { item { - val openLabel = if (enclosure.name.isBlank()) { - stringResource(R.string.open_enclosed_media) + 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 { - stringResource(R.string.open_enclosed_media_file, enclosure.name) - } - Column( - modifier = Modifier - .width(dimens.maxReaderWidth), - ) { + val openLabel = if (enclosure.name.isBlank()) { + stringResource(R.string.open_enclosed_media) + } else { + stringResource(R.string.open_enclosed_media_file, enclosure.name) + } ProvideScaledText( style = MaterialTheme.typography.bodyLarge.merge( LinkTextStyle(), @@ -175,6 +226,7 @@ fun ReaderView( Text( text = openLabel, modifier = Modifier + .width(dimens.maxReaderWidth) .clickable { onEnclosureClick() } @@ -189,7 +241,11 @@ fun ReaderView( } catch (e: Exception) { // Observed nullpointer exception when setting customActions // No clue why it could be null - Log.e("FeederReaderScreen", "Exception in semantics", e) + Log.e( + LOG_TAG, + "Exception in semantics", + e, + ) } }, ) @@ -202,3 +258,5 @@ fun ReaderView( } } } + +private const val LOG_TAG = "FEEDER_READER" diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/AnnotatedString.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/AnnotatedString.kt index b9d3ad852..d9246bb01 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/AnnotatedString.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/AnnotatedString.kt @@ -35,6 +35,20 @@ class AnnotatedParagraphStringBuilder { return false } + val endsWithNonBreakingSpace: Boolean + get() { + if (mLastTwoChars.isEmpty()) { + return false + } + mLastTwoChars.peekLatest()?.let { latest -> + if (latest.code == 160) { + return true + } + } + + return false + } + fun pushVerbatimTtsAnnotation(verbatim: String) = builder.pushTtsAnnotation(VerbatimTtsAnnotation(verbatim)) diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/AnnotatedStringComposer.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/AnnotatedStringComposer.kt index 6ffaa76b4..aee843ec6 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/AnnotatedStringComposer.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/AnnotatedStringComposer.kt @@ -9,7 +9,8 @@ class AnnotatedStringComposer : HtmlParser() { strings override fun emitParagraph(): Boolean { - if (builder.isEmpty()) { + // List items emit dots and non-breaking space. Don't newline after that + if (builder.isEmpty() || builder.endsWithNonBreakingSpace) { // Nothing to emit, and nothing to reset return false } diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/EagerComposer.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/EagerComposer.kt index 0c24b359b..960283150 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/EagerComposer.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/EagerComposer.kt @@ -43,7 +43,8 @@ class EagerComposer( } override fun emitParagraph(): Boolean { - if (builder.isEmpty()) { + // List items emit dots and non-breaking space. Don't newline after that + if (builder.isEmpty() || builder.endsWithNonBreakingSpace) { // Nothing to emit, and nothing to reset return false } diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/HtmlToComposable.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/HtmlToComposable.kt index 47081887a..0c4153b2f 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/HtmlToComposable.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/HtmlToComposable.kt @@ -578,7 +578,7 @@ private fun HtmlComposer.appendTextChildren( .forEach { listItem -> withParagraph { // no break space - append("• ") + append("•\u00A0") appendTextChildren( listItem.childNodes(), baseUrl = baseUrl, @@ -595,7 +595,7 @@ private fun HtmlComposer.appendTextChildren( .forEachIndexed { i, listItem -> withParagraph { // no break space - append("${i + 1}. ") + append("${i + 1}.\u00A0") appendTextChildren( listItem.childNodes(), baseUrl = baseUrl, diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/LazyListComposer.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/LazyListComposer.kt index 3ec8eed55..194821ba2 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/LazyListComposer.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/text/LazyListComposer.kt @@ -11,7 +11,8 @@ class LazyListComposer( ) : HtmlComposer() { override fun emitParagraph(): Boolean { - if (builder.isEmpty()) { + // List items emit dots and non-breaking space. Don't newline after that + if (builder.isEmpty() || builder.endsWithNonBreakingSpace) { // Nothing to emit, and nothing to reset return false } diff --git a/app/stdout b/app/stdout new file mode 100644 index 000000000..01965882a --- /dev/null +++ b/app/stdout @@ -0,0 +1 @@ +No issues found.