diff --git a/.editorconfig b/.editorconfig index 01c20e5bc..f1391ba66 100644 --- a/.editorconfig +++ b/.editorconfig @@ -15,6 +15,7 @@ ij_wrap_on_typing = false [*.{kt,kts}] ktlint_code_style = ktlint_official +ktlint_function_naming_ignore_when_annotated_with=Composable twitter_compose_allowed_composition_locals = LocalTypographySettings,LocalDimens,LocalWindowSize,LocalFoldableHinge [{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.opml,*.rng,*.tld,*.wsdl,*.xml,*.xsd,*.xsl,*.xslt,*.xul,rss_kuketz,rss_morningpaper}] 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..88625ab66 --- /dev/null +++ b/app/src/androidTest/java/com/nononsenseapps/feeder/db/room/TestMigrationFrom31To32.kt @@ -0,0 +1,75 @@ +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 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 a3a80b156..9bf8f1e15 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/archmodel/Repository.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/archmodel/Repository.kt @@ -796,6 +796,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 00904acc6..c97bd3cd9 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() { @@ -126,12 +126,23 @@ fun getAllMigrations(di: DI) = 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 73d13f4d1..76945cd5f 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 @@ -27,11 +28,10 @@ import com.nononsenseapps.feeder.db.COL_TITLE import com.nononsenseapps.feeder.db.COL_WORD_COUNT import com.nononsenseapps.feeder.db.COL_WORD_COUNT_FULL import com.nononsenseapps.feeder.db.FEED_ITEMS_TABLE_NAME +import com.nononsenseapps.feeder.model.ParsedArticle +import com.nononsenseapps.feeder.model.ParsedFeed import com.nononsenseapps.feeder.model.host import com.nononsenseapps.feeder.ui.text.HtmlToPlainTextConverter -import com.nononsenseapps.feeder.util.relativeLinkIntoAbsolute -import com.nononsenseapps.feeder.util.sloppyLinkToStrictURL -import com.nononsenseapps.jsonfeed.Item import java.net.URI import java.time.Instant import java.time.ZoneOffset @@ -75,6 +75,7 @@ data class FeedItem @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, @@ -117,9 +118,9 @@ data class FeedItem get() = readTime == null fun updateFromParsedEntry( - entry: Item, + entry: ParsedArticle, entryGuid: String, - feed: com.nononsenseapps.jsonfeed.Feed, + feed: ParsedFeed, ) { val converter = HtmlToPlainTextConverter() // Be careful about nulls. @@ -141,26 +142,18 @@ data class FeedItem // 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 b29ab4674..7c642ca20 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 @@ -57,6 +59,7 @@ data class FeedItemWithFeed @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/model/FeedParser.kt b/app/src/main/java/com/nononsenseapps/feeder/model/FeedParser.kt index 5483c1662..8890b09f0 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/model/FeedParser.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/model/FeedParser.kt @@ -9,7 +9,10 @@ import com.nononsenseapps.feeder.util.flatMap import com.nononsenseapps.feeder.util.relativeLinkIntoAbsolute import com.nononsenseapps.feeder.util.relativeLinkIntoAbsoluteOrThrow import com.nononsenseapps.feeder.util.sloppyLinkToStrictURLOrNull +import com.nononsenseapps.jsonfeed.Attachment +import com.nononsenseapps.jsonfeed.Author import com.nononsenseapps.jsonfeed.Feed +import com.nononsenseapps.jsonfeed.Item import com.nononsenseapps.jsonfeed.JsonFeedParser import com.rometools.rome.io.SyndFeedInput import com.rometools.rome.io.XmlReader @@ -204,7 +207,7 @@ class FeedParser(override val di: DI) : DIAware { */ private suspend fun curl(url: URL) = client.curl(url) - suspend fun parseFeedUrl(url: URL): Either { + suspend fun parseFeedUrl(url: URL): Either { return client.curlAndOnResponse(url) { parseFeedResponse(it) } @@ -214,7 +217,7 @@ class FeedParser(override val di: DI) : DIAware { } } - internal fun parseFeedResponse(response: Response): Either { + internal fun parseFeedResponse(response: Response): Either { return response.body?.use { // OkHttp string method handles BOM and Content-Type header in request parseFeedResponse( @@ -230,7 +233,7 @@ class FeedParser(override val di: DI) : DIAware { fun parseFeedResponse( url: URL, responseBody: ResponseBody, - ): Either { + ): Either { return when (responseBody.contentType()?.subtype?.contains("json")) { true -> Either.catching( @@ -238,7 +241,7 @@ class FeedParser(override val di: DI) : DIAware { JsonFeedParseError(url = url.toString(), throwable = t) }, ) { - jsonFeedParser.parseJson(responseBody) + jsonFeedParser.parseJson(responseBody).asParsedFeed() } else -> parseRssAtom(url, responseBody) @@ -260,7 +263,7 @@ class FeedParser(override val di: DI) : DIAware { url: URL, body: String, contentType: MediaType?, - ): Either { + ): Either { return when (contentType?.subtype?.contains("json")) { true -> Either.catching( @@ -268,7 +271,7 @@ class FeedParser(override val di: DI) : DIAware { JsonFeedParseError(url = url.toString(), throwable = t) }, ) { - jsonFeedParser.parseJson(body) + jsonFeedParser.parseJson(body).asParsedFeed() } else -> parseRssAtom(url, body) @@ -286,7 +289,7 @@ class FeedParser(override val di: DI) : DIAware { private fun parseRssAtom( url: URL, responseBody: ResponseBody, - ): Either { + ): Either { val contentType = responseBody.contentType() val validMimeType = when (contentType?.type) { @@ -334,7 +337,7 @@ class FeedParser(override val di: DI) : DIAware { internal fun parseRssAtom( baseUrl: URL, body: String, - ): Either { + ): Either { return Either.catching( onCatch = { t -> RSSParseError(url = baseUrl.toString(), throwable = t) @@ -359,6 +362,54 @@ class FeedParser(override val di: DI) : DIAware { } } +private fun Feed.asParsedFeed() = + ParsedFeed( + title = title, + home_page_url = home_page_url, + feed_url = feed_url, + description = description, + user_comment = user_comment, + next_url = next_url, + icon = icon, + favicon = favicon, + author = author?.asParsedAuthor(), + expired = expired, + items = items?.map { it.asParsedArticle() }, + ) + +private fun Item.asParsedArticle() = + ParsedArticle( + id = id, + url = url, + external_url = external_url, + title = title, + content_html = content_html, + content_text = content_text, + summary = summary, + image = image?.let { MediaImage(url = it, width = null, height = null) }, + date_published = date_published, + date_modified = date_modified, + author = author?.asParsedAuthor(), + tags = tags, + attachments = attachments?.map { it.asParsedEnclosure() }, + ) + +private fun Attachment.asParsedEnclosure() = + ParsedEnclosure( + title = title, + url = url, + mime_type = mime_type, + size_in_bytes = size_in_bytes, + duration_in_seconds = duration_in_seconds, + ) + +private fun Author?.asParsedAuthor() = + ParsedAuthor( + name = this?.name, + url = this?.url, + avatar = this?.avatar, + ) + class FeedParsingError(val url: URL, e: Throwable) : Exception(e.message, e) suspend fun OkHttpClient.getResponse( diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/ParsedArticle.kt b/app/src/main/java/com/nononsenseapps/feeder/model/ParsedArticle.kt new file mode 100644 index 000000000..99a5836ff --- /dev/null +++ b/app/src/main/java/com/nononsenseapps/feeder/model/ParsedArticle.kt @@ -0,0 +1,17 @@ +package com.nononsenseapps.feeder.model + +data class ParsedArticle( + val id: String?, + val url: String? = null, + val external_url: String? = null, + val title: String? = null, + val content_html: String? = null, + val content_text: String? = null, + val summary: String? = null, + val image: ThumbnailImage? = null, + val date_published: String? = null, + val date_modified: String? = null, + val author: ParsedAuthor? = null, + val tags: List? = null, + val attachments: List? = null, +) diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/ParsedAuthor.kt b/app/src/main/java/com/nononsenseapps/feeder/model/ParsedAuthor.kt new file mode 100644 index 000000000..f3d390a9d --- /dev/null +++ b/app/src/main/java/com/nononsenseapps/feeder/model/ParsedAuthor.kt @@ -0,0 +1,7 @@ +package com.nononsenseapps.feeder.model + +data class ParsedAuthor( + val name: String? = null, + val url: String? = null, + val avatar: String? = null, +) diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/ParsedEnclosure.kt b/app/src/main/java/com/nononsenseapps/feeder/model/ParsedEnclosure.kt new file mode 100644 index 000000000..1f71e9bdd --- /dev/null +++ b/app/src/main/java/com/nononsenseapps/feeder/model/ParsedEnclosure.kt @@ -0,0 +1,9 @@ +package com.nononsenseapps.feeder.model + +data class ParsedEnclosure( + val url: String?, + val mime_type: String? = null, + val title: String? = null, + val size_in_bytes: Long? = null, + val duration_in_seconds: Long? = null, +) diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/ParsedFeed.kt b/app/src/main/java/com/nononsenseapps/feeder/model/ParsedFeed.kt new file mode 100644 index 000000000..a3f10d031 --- /dev/null +++ b/app/src/main/java/com/nononsenseapps/feeder/model/ParsedFeed.kt @@ -0,0 +1,15 @@ +package com.nononsenseapps.feeder.model + +data class ParsedFeed( + val title: String?, + val home_page_url: String? = null, + val feed_url: String? = null, + val description: String? = null, + val user_comment: String? = null, + val next_url: String? = null, + val icon: String? = null, + val favicon: String? = null, + val author: ParsedAuthor? = null, + val expired: Boolean? = null, + val items: List?, +) 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 d45ee77da..fc4065375 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/model/RssLocalSync.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/model/RssLocalSync.kt @@ -14,7 +14,6 @@ import com.nononsenseapps.feeder.util.flatMap import com.nononsenseapps.feeder.util.left import com.nononsenseapps.feeder.util.logDebug import com.nononsenseapps.feeder.util.sloppyLinkToStrictURLNoThrows -import com.nononsenseapps.jsonfeed.Item import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asCoroutineDispatcher @@ -391,5 +390,5 @@ class RssLocalSync(override val di: DI) : DIAware { * Remember that text or title literally can mean injection problems if the contain % or similar, * so do NOT use them literally */ -private val Item.alternateId: String +private val ParsedArticle.alternateId: String get() = "$id|${content_text.hashCode()}|${title.hashCode()}" diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/ThumbnailImage.kt b/app/src/main/java/com/nononsenseapps/feeder/model/ThumbnailImage.kt new file mode 100644 index 000000000..3c417342b --- /dev/null +++ b/app/src/main/java/com/nononsenseapps/feeder/model/ThumbnailImage.kt @@ -0,0 +1,86 @@ +package com.nononsenseapps.feeder.model + +/** + * 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) { + override fun equals(other: Any?): Boolean { + return if (other is ImageFromHTML) { + return url == other.url && width == other.width && height == other.height + } else { + false + } + } + + override fun hashCode(): Int { + val prime = 31 + var result = 1 + result = prime * result + javaClass.simpleName.hashCode() + result = prime * result + url.hashCode() + result = prime * result + width.hashCode() + result = prime * result + height.hashCode() + return result + } + + override fun toString(): String { + return "ImageFromHTML(url='$url', width=$width, height=$height, fromBody=$fromBody)" + } +} + +class EnclosureImage(url: String, width: Int?, height: Int?) : + ThumbnailImage(url, width, height, fromBody = false) { + override fun equals(other: Any?): Boolean { + return if (other is EnclosureImage) { + return url == other.url && width == other.width && height == other.height + } else { + false + } + } + + override fun hashCode(): Int { + val prime = 31 + var result = 1 + result = prime * result + javaClass.simpleName.hashCode() + result = prime * result + url.hashCode() + result = prime * result + width.hashCode() + result = prime * result + height.hashCode() + return result + } + + override fun toString(): String { + return "EnclosureImage(url='$url', width=$width, height=$height, fromBody=$fromBody)" + } +} + +class MediaImage(url: String, width: Int?, height: Int?) : + ThumbnailImage(url, width, height, fromBody = false) { + override fun equals(other: Any?): Boolean { + return if (other is MediaImage) { + return url == other.url && width == other.width && height == other.height + } else { + false + } + } + + override fun hashCode(): Int { + val prime = 31 + var result = 1 + result = prime * result + javaClass.simpleName.hashCode() + result = prime * result + url.hashCode() + result = prime * result + width.hashCode() + result = prime * result + height.hashCode() + return result + } + + override fun toString(): String { + return "MediaImage(url='$url', width=$width, height=$height, fromBody=$fromBody)" + } +} 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 d2044be42..2e68480bc 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 @@ -430,6 +430,9 @@ fun ArticleContent( else -> null }, + image = viewState.image, + imageFromBody = viewState.imageFromBody, + isFeedText = viewState.textToDisplay == TextToDisplay.DEFAULT, ) { // 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 8ab913e9c..b2963dbde 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 @@ -364,6 +364,8 @@ class FeedArticleViewModel( TextToDisplay.FAILED_NOT_HTML, -> 0 }, + image = article.image, + imageFromBody = article.imageFromBody, ) } .stateIn( @@ -557,6 +559,8 @@ interface ArticleScreenViewState { val isBookmarked: Boolean val keyHolder: ArticleItemKeyHolder val wordCount: Int + val image: String? + val imageFromBody: Boolean } interface ArticleItemKeyHolder { @@ -645,6 +649,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 a700aa44c..5398da889 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,15 @@ fun ReaderView( wordCount: Int, onEnclosureClick: () -> Unit, onFeedTitleClick: () -> Unit, + enclosure: Enclosure, + articleTitle: String, + feedTitle: String, + authorDate: String?, + image: String?, + imageFromBody: Boolean, + isFeedText: 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 @@ -142,17 +145,19 @@ 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 = @@ -206,7 +211,10 @@ fun ReaderView( ) val seconds = "%02d".format(readTimeSecs % 60) val readTimeText = - pluralStringResource(id = R.plurals.n_minutes, count = readTimeSecs / 60) + pluralStringResource( + id = R.plurals.n_minutes, + count = readTimeSecs / 60, + ) .format( "${readTimeSecs / 60}:$seconds", ) @@ -234,47 +242,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) @@ -320,6 +289,51 @@ fun ReaderView( } } + // Don't show image for full text articles since it's typically inside the full article + if (isFeedText && !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() } } @@ -335,6 +349,13 @@ private fun ReaderPreview() { wordCount = 9700, 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, + isFeedText = true, ) {} } } 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 5430324a1..9c94cad87 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 @@ -66,6 +66,7 @@ import com.nononsenseapps.feeder.ui.compose.theme.BlockQuoteStyle import com.nononsenseapps.feeder.ui.compose.theme.CodeBlockBackground import com.nononsenseapps.feeder.ui.compose.theme.CodeBlockStyle import com.nononsenseapps.feeder.ui.compose.theme.CodeInlineStyle +import com.nononsenseapps.feeder.ui.compose.theme.FeederTheme import com.nononsenseapps.feeder.ui.compose.theme.LinkTextStyle import com.nononsenseapps.feeder.ui.compose.theme.LocalDimens import com.nononsenseapps.feeder.ui.compose.theme.hasImageAspectRatioInReader @@ -524,6 +525,19 @@ private fun HtmlComposer.appendTextChildren( } } + "figcaption" -> { + // If not inside figure then FullTextParsing just failed + if (element.parent()?.tagName() == "figure") { + appendTextChildren( + nodes = element.childNodes(), + preFormatted = preFormatted, + baseUrl = baseUrl, + onLinkClick = onLinkClick, + keyHolder = keyHolder, + ) + } + } + "figure" -> { emitParagraph() @@ -1178,18 +1192,22 @@ private fun TestIt() {

In Gimp you go to Image in the top menu bar and select Mode followed by Indexed. Now you see a popup where you can select the number of colors for a generated optimum palette.

You’ll have to experiment a little because it will depend on your image.

I used this approach to shrink the size of the cover image in the_zopfli post from a 37KB (JPG) to just 15KB (PNG, all PNG sizes listed include Zopfli compression btw).

Straight JPG to PNG conversion: 124KB

PNG version RGB colors

First off, I exported the JPG file as a PNG file. This PNG file had a whopping 124KB! Clearly there was some bloat being stored.

256 colors: 40KB

Reducing from RGB to only 256 colors has no visible effect to my eyes.

256 colors

128 colors: 34KB

Still no difference.

128 colors

64 colors: 25KB

You can start to see some artifacting in the shadow behind the text.

64 colors

32 colors: 15KB

In my opinion this is the sweet spot. The shadow artifacting is barely noticable but the size is significantly reduced.

32 colors

16 colors: 11KB

Clear artifacting in the text shadow and the yellow (fire?) in the background has developed an outline.

16 colors

8 colors: 7.3KB

The broom has shifted in color from a clear brown to almost grey. Text shadow is just a grey blob at this point. Even clearer outline developed on the yellow background.

8 colors

4 colors: 4.3KB

Interestingly enough, I think 4 colors looks better than 8 colors. The outline in the background has disappeared because there’s not enough color spectrum to render it. The broom is now black and filled areas tend to get a white separator to the outlines.

4 colors

2 colors: 2.4KB

Well, at least the silhouette is well defined at this point I guess.

2 colors


Other posts in the Migrating from Ghost to Hugo series:

""".trimIndent() - html.byteInputStream().use { stream -> - LazyColumn { - htmlFormattedText( - inputStream = stream, - baseUrl = "https://cowboyprogrammer.org", - keyHolder = - object : ArticleItemKeyHolder { - override fun getAndIncrementKey(): Long { - return Random.nextLong() - } - }, - ) {} + FeederTheme { + Surface { + html.byteInputStream().use { stream -> + LazyColumn { + htmlFormattedText( + inputStream = stream, + baseUrl = "https://cowboyprogrammer.org", + keyHolder = + object : ArticleItemKeyHolder { + override fun getAndIncrementKey(): Long { + return Random.nextLong() + } + }, + ) {} + } + } } } } @@ -1320,21 +1338,21 @@ fun Element.appendCorrectlyNormalizedWhiteSpaceRecursively( } } -private const val space = ' ' -private const val tab = '\t' -private const val linefeed = '\n' -private const val carriageReturn = '\r' +private const val SPACE = ' ' +private const val TAB = '\t' +private const val LINE_FEED = '\n' +private const val CARRIAGE_RETURN = '\r' // 12 is form feed which as no escape in kotlin -private const val formFeed = 12.toChar() +private const val FORM_FEED = 12.toChar() // 160 is   (non-breaking space). Not in the spec but expected. -private const val nonBreakableSpace = 160.toChar() +private const val NON_BREAKING_SPACE = 160.toChar() private fun isCollapsableWhiteSpace(c: String) = c.firstOrNull()?.let { isCollapsableWhiteSpace(it) } ?: false private fun isCollapsableWhiteSpace(c: Char) = - c == space || c == tab || c == linefeed || c == carriageReturn || c == formFeed || c == nonBreakableSpace + c == SPACE || c == TAB || c == LINE_FEED || c == CARRIAGE_RETURN || c == FORM_FEED || c == NON_BREAKING_SPACE /** * Super basic function to strip html formatting from alt-texts. 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 4a613c8ac..510b6eb77 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/util/HtmlUtils.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/util/HtmlUtils.kt @@ -1,12 +1,13 @@ package com.nononsenseapps.feeder.util +import com.nononsenseapps.feeder.model.ImageFromHTML import org.jsoup.Jsoup import org.jsoup.parser.Parser.unescapeEntities fun findFirstImageLinkInHtml( text: String?, baseUrl: String?, -): String? = +): ImageFromHTML? = if (text != null) { val doc = unescapeEntities(text, true).byteInputStream().use { @@ -18,12 +19,16 @@ fun findFirstImageLinkInHtml( .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 1db6c9d2e..5033f67f1 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/util/RomeExtensions.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/util/RomeExtensions.kt @@ -1,11 +1,14 @@ package com.nononsenseapps.feeder.util import android.util.Log +import com.nononsenseapps.feeder.model.EnclosureImage +import com.nononsenseapps.feeder.model.MediaImage +import com.nononsenseapps.feeder.model.ParsedArticle +import com.nononsenseapps.feeder.model.ParsedAuthor +import com.nononsenseapps.feeder.model.ParsedEnclosure +import com.nononsenseapps.feeder.model.ParsedFeed +import com.nononsenseapps.feeder.model.ThumbnailImage import com.nononsenseapps.feeder.ui.text.HtmlToPlainTextConverter -import com.nononsenseapps.jsonfeed.Attachment -import com.nononsenseapps.jsonfeed.Author -import com.nononsenseapps.jsonfeed.Feed -import com.nononsenseapps.jsonfeed.Item import com.rometools.modules.mediarss.MediaEntryModule import com.rometools.modules.mediarss.MediaModule import com.rometools.modules.mediarss.types.MediaContent @@ -21,8 +24,8 @@ import java.time.Instant import java.time.ZoneOffset import java.time.ZonedDateTime -fun SyndFeed.asFeed(baseUrl: URL): Feed { - val feedAuthor: Author? = this.authors?.firstOrNull()?.asAuthor() +fun SyndFeed.asFeed(baseUrl: URL): ParsedFeed { + val feedAuthor: ParsedAuthor? = this.authors?.firstOrNull()?.asAuthor() val siteUrl = relativeLinkIntoAbsoluteOrNull( @@ -47,7 +50,7 @@ fun SyndFeed.asFeed(baseUrl: URL): Feed { } try { - return Feed( + return ParsedFeed( title = plainTitle(), home_page_url = siteUrl, feed_url = @@ -67,8 +70,8 @@ fun SyndFeed.asFeed(baseUrl: URL): Feed { fun SyndEntry.asItem( baseUrl: URL, - feedAuthor: Author? = null, -): Item { + feedAuthor: ParsedAuthor? = null, +): ParsedArticle { try { val contentText = contentText().orIfBlank { @@ -78,17 +81,17 @@ fun SyndEntry.asItem( val image = thumbnail(baseUrl)?.let { img -> when { - img.startsWith("data:") -> null + img.url.startsWith("data:") -> null else -> img } } val writer = when (author?.isNotBlank()) { - true -> Author(name = author) + true -> ParsedAuthor(name = author) else -> feedAuthor } - return Item( + return ParsedArticle( id = relativeLinkIntoAbsoluteOrNull(baseUrl, this.uri), url = linkToHtml(baseUrl), title = plainTitle(), @@ -135,8 +138,8 @@ fun SyndEntry.linkToHtml(feedBaseUrl: URL): String? { return null } -fun SyndEnclosure.asAttachment(baseUrl: URL): Attachment { - return Attachment( +fun SyndEnclosure.asAttachment(baseUrl: URL): ParsedEnclosure { + return ParsedEnclosure( url = relativeLinkIntoAbsoluteOrNull( baseUrl, @@ -147,14 +150,14 @@ fun SyndEnclosure.asAttachment(baseUrl: URL): Attachment { ) } -fun SyndPerson.asAuthor(): Author { +fun SyndPerson.asAuthor(): ParsedAuthor { val url: String? = when { this.uri != null -> this.uri this.email != null -> "mailto:${this.email}" else -> null } - return Author( + return ParsedAuthor( name = this.name, url = url, ) @@ -237,16 +240,16 @@ 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) } @@ -255,7 +258,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() @@ -264,60 +267,54 @@ 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 = +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) } } @@ -325,10 +322,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/test/java/com/nononsenseapps/feeder/model/FeedParserClientTest.kt b/app/src/test/java/com/nononsenseapps/feeder/model/FeedParserClientTest.kt index 516b58e04..dc301a324 100644 --- a/app/src/test/java/com/nononsenseapps/feeder/model/FeedParserClientTest.kt +++ b/app/src/test/java/com/nononsenseapps/feeder/model/FeedParserClientTest.kt @@ -168,6 +168,6 @@ class FeedParserClientTest : DIAware { val url = server.url("/foo").toUrl() // This should not crash val result = feedParser.parseFeedUrl(url) - assertEquals("http://www.questionablecontent.net/comics/4776.png", result.getOrNull()?.items?.first()?.image) + assertEquals("http://www.questionablecontent.net/comics/4776.png", result.getOrNull()?.items?.first()?.image?.url) } } diff --git a/app/src/test/java/com/nononsenseapps/feeder/model/FeedParserTest.kt b/app/src/test/java/com/nononsenseapps/feeder/model/FeedParserTest.kt index 6b86c64fd..6078f01b8 100644 --- a/app/src/test/java/com/nononsenseapps/feeder/model/FeedParserTest.kt +++ b/app/src/test/java/com/nononsenseapps/feeder/model/FeedParserTest.kt @@ -1,7 +1,6 @@ package com.nononsenseapps.feeder.model import com.nononsenseapps.feeder.di.networkModule -import com.nononsenseapps.jsonfeed.Author import com.nononsenseapps.jsonfeed.cachingHttpClient import kotlinx.coroutines.runBlocking import okhttp3.MediaType.Companion.toMediaTypeOrNull @@ -69,7 +68,7 @@ class FeedParserTest : DIAware { ) val item = feed.getOrNull()?.items!!.first() - assertEquals(Author(name = "0235"), item.author) + assertEquals(ParsedAuthor(name = "0235"), item.author) } } @@ -116,7 +115,7 @@ class FeedParserTest : DIAware { val item = feed.getOrNull()?.items!![0] assertEquals( "http://s1.lemde.fr/image/2018/02/11/644x322/5255112_3_a8dc_martin-fourcade_02be61d126b2da39d977b2e1902c819a.jpg", - item.image, + item.image?.url, ) } } @@ -136,7 +135,7 @@ class FeedParserTest : DIAware { val item = feed.getOrNull()?.items!!.first() assertEquals("Can You Observe a Typical Universe?", item.title) - assertEquals("https://i2.ytimg.com/vi/q-6oU3jXAho/hqdefault.jpg", item.image) + assertEquals("https://i2.ytimg.com/vi/q-6oU3jXAho/hqdefault.jpg", item.image?.url) assertTrue { item.content_text!!.startsWith("Sign Up on Patreon to get access to the Space Time Discord!") } @@ -155,7 +154,7 @@ class FeedParserTest : DIAware { assertEquals("1.4. Et les réseaux sociaux ?", item.title) assertEquals( "https://framatube.org/static/thumbnails/ed5c048d-01f3-4ceb-97db-6e278de512b0.jpg", - item.image, + item.image?.url, ) assertTrue { item.content_text!!.startsWith("MOOC CHATONS#1 - Internet") @@ -179,7 +178,7 @@ class FeedParserTest : DIAware { assertEquals("Камеди Клаб: «3 сентября»", item.title) assertEquals( "https://pic.rutubelist.ru/video/93/24/93245691f0e18d063da5fa5cd60fa6de.jpg?size=l", - item.image, + item.image?.url, ) } @@ -199,7 +198,7 @@ class FeedParserTest : DIAware { assertEquals( "https://cdn.myanimelist.net/s/common/uploaded_files/1664092688-dd34666e64d7ae624e6e2c70087c181f.jpeg", - item.image, + item.image?.url, ) } @@ -219,7 +218,7 @@ class FeedParserTest : DIAware { assertEquals( "https://i.guim.co.uk/img/media/c4d7049b24ee34d1c4c630c751094cabc57c54f6/0_32_6000_3601/master/6000.jpg?width=460&quality=85&auto=format&fit=max&s=919d72fef6d4f3469aff69e94964126c", - item.image, + item.image?.url, ) } @@ -397,7 +396,7 @@ class FeedParserTest : DIAware { assertEquals("https://lineageos.org/Changelog-16/", feed.getOrNull()?.items?.get(0)?.url) assertEquals( "https://lineageos.org/images/2018-02-25/lineageos-15.1-hero.png", - feed.getOrNull()?.items?.get(0)?.image, + feed.getOrNull()?.items?.get(0)?.image?.url, ) } @@ -412,19 +411,19 @@ class FeedParserTest : DIAware { assertEquals(17, feed.getOrNull()?.items!!.size) - val (_, _, _, title, _, _, summary, image) = feed.getOrNull()?.items!![9] + val item = feed.getOrNull()?.items!![9] - assertEquals("http://research.swtch.com/qr-bbc.png", image) + assertEquals("http://research.swtch.com/qr-bbc.png", item.image?.url) assertEquals( "QArt Codes", - title, + item.title, ) // Style tags should be ignored assertEquals( "QR codes are 2-dimensional bar codes that encode arbitrary text strings. A common use of QR codes is to encode URLs so that people can scan a QR code (for example, on an advertising poster, building r", - summary, + item.summary, ) } @@ -439,10 +438,10 @@ class FeedParserTest : DIAware { assertEquals(17, feed.getOrNull()?.items!!.size) - val (_, _, _, _, _, _, _, _, _, _, _, author) = feed.getOrNull()?.items!![9] + val item = feed.getOrNull()?.items!![9] assertEquals("Russ Cox", feed.getOrNull()!!.author!!.name) - assertEquals(feed.getOrNull()!!.author, author) + assertEquals(feed.getOrNull()!!.author!!.name, item.author?.name) } @Test @@ -457,7 +456,7 @@ class FeedParserTest : DIAware { val (_, _, _, title, _, _, _, image) = feed.getOrNull()?.items!![0] - assertEquals("https://nixos.org/logo/nixos-logo-18.09-jellyfish-lores.png", image) + assertEquals("https://nixos.org/logo/nixos-logo-18.09-jellyfish-lores.png", image?.url) assertEquals("NixOS 18.09 released", title) } @@ -507,7 +506,7 @@ class FeedParserTest : DIAware { assertEquals( "http://www.cyklistbloggen.se/wp-content/uploads/2014/01/Danviksklippan-skyltad.jpg", - image, + image?.url, ) assertEquals( @@ -537,7 +536,7 @@ class FeedParserTest : DIAware { assertEquals( "https://cowboyprogrammer.org/images/zopfli_all_the_things.jpg", - entry.image, + entry.image?.url, ) // Snippet should not contain images @@ -560,113 +559,109 @@ class FeedParserTest : DIAware { @Throws(Exception::class) fun rss() = runBlocking { - val feed = cornucopiaRss.use { feedParser.parseFeedResponse(it) } - val (_, _, home_page_url, feed_url, _, _, _, _, _, _, _, _, items) = feed.getOrNull()!! + val feed = cornucopiaRss.use { feedParser.parseFeedResponse(it) }.getOrNull()!! - assertEquals("http://cornucopia.cornubot.se/", home_page_url) - assertEquals("https://cornucopia.cornubot.se/feeds/posts/default?alt=rss", feed_url) + assertEquals("http://cornucopia.cornubot.se/", feed.home_page_url) + assertEquals("https://cornucopia.cornubot.se/feeds/posts/default?alt=rss", feed.feed_url) - assertEquals(25, items!!.size) - val (_, _, _, title, content_html, _, summary, image, _, _, _, _, _, attachments) = items[0] + assertEquals(25, feed.items!!.size) + val item = feed.items!!.first() assertEquals( "Tredje månaden med överhettad svensk ekonomi - tydlig säljsignal för börsen", - title, + item.title, ) assertEquals( "Tredje månaden med överhettad svensk ekonomi - tydlig säljsignal för börsen", - title, + item.title, ) assertEquals( "För tredje månaden på raken ligger Konjunkturinsitutets barometerindikator (\"konjunkturbarometern\") kvar i överhettat läge. Det råder alltså en klart och tydligt långsiktig säljsignal i enlighet med k", - summary, + item.summary, ) - assertTrue(content_html!!.startsWith("För tredje månaden på raken")) + assertTrue(item.content_html!!.startsWith("För tredje månaden på raken")) assertEquals( "https://1.bp.blogspot.com/-hD_mqKJx-XY/WLwTIKSEt6I/AAAAAAAAqfI/sztWEjwSYAoN22y_YfnZ-yotKjQsypZHACLcB/s72-c/konj.png", - image, + item.image?.url, ) - assertEquals?>(emptyList(), attachments) + assertEquals?>(emptyList(), item.attachments) } @Test @Throws(Exception::class) fun atom() = runBlocking { - val feed = cornucopiaAtom.use { feedParser.parseFeedResponse(it) } - val (_, _, home_page_url, feed_url, _, _, _, _, _, _, _, _, items) = feed.getOrNull()!! + val feed = cornucopiaAtom.use { feedParser.parseFeedResponse(it) }.getOrNull()!! - assertEquals("http://cornucopia.cornubot.se/", home_page_url) - assertEquals("http://www.blogger.com/feeds/8354057230547055221/posts/default", feed_url) + assertEquals("http://cornucopia.cornubot.se/", feed.home_page_url) + assertEquals("http://www.blogger.com/feeds/8354057230547055221/posts/default", feed.feed_url) - assertEquals(25, items!!.size) - val (_, _, _, title, content_html, _, summary, image, _, _, _, _, _, attachments) = items[0] + assertEquals(25, feed.items!!.size) + val item = feed.items!!.first() assertEquals( "Tredje månaden med överhettad svensk ekonomi - tydlig säljsignal för börsen", - title, + item.title, ) assertEquals( "Tredje månaden med överhettad svensk ekonomi - tydlig säljsignal för börsen", - title, + item.title, ) assertEquals( "För tredje månaden på raken ligger Konjunkturinsitutets barometerindikator (\"konjunkturbarometern\") kvar i överhettat läge. Det råder alltså en klart och tydligt långsiktig säljsignal i enlighet med k", - summary, + item.summary, ) - assertTrue(content_html!!.startsWith("För tredje månaden på raken")) + assertTrue(item.content_html!!.startsWith("För tredje månaden på raken")) assertEquals( "https://1.bp.blogspot.com/-hD_mqKJx-XY/WLwTIKSEt6I/AAAAAAAAqfI/sztWEjwSYAoN22y_YfnZ-yotKjQsypZHACLcB/s72-c/konj.png", - image, + item.image?.url, ) - assertEquals?>(emptyList(), attachments) + assertEquals?>(emptyList(), item.attachments) } @Test @Throws(Exception::class) fun atomCowboy() = runBlocking { - val feed = cowboyAtom.use { feedParser.parseFeedResponse(it) } - val (_, _, _, _, _, _, _, icon, _, _, _, _, items) = feed.getOrNull()!! + val feed = cowboyAtom.use { feedParser.parseFeedResponse(it) }.getOrNull()!! - assertEquals(15, items!!.size) - val (id, _, _, _, _, _, _, image, _, date_published) = items[1] + assertEquals(15, feed.items!!.size) + val item = feed.items!![1] - assertEquals("http://cowboyprogrammer.org/dummy-id-to-distinguis-from-alternate-link", id) - assertTrue(date_published!!.contains("2016"), "Should take the updated timestamp") + assertEquals("http://cowboyprogrammer.org/dummy-id-to-distinguis-from-alternate-link", item.id) + assertTrue(item.date_published!!.contains("2016"), "Should take the updated timestamp") assertEquals( "http://localhost:1313/images/zopfli_all_the_things.jpg", - image, + item.image?.url, ) - assertEquals("http://localhost:1313/css/images/logo.png", icon) + assertEquals("http://localhost:1313/css/images/logo.png", feed.icon) } @Test @Throws(Exception::class) fun morningPaper() = runBlocking { - val feed = morningPaper.use { feedParser.parseFeedResponse(it) } - val (_, _, home_page_url, feed_url, _, _, _, _, _, _, _, _, items) = feed.getOrNull()!! + val feed = morningPaper.use { feedParser.parseFeedResponse(it) }.getOrNull()!! - assertEquals("https://blog.acolyer.org", home_page_url) - assertEquals("https://blog.acolyer.org/feed/", feed_url) + assertEquals("https://blog.acolyer.org", feed.home_page_url) + assertEquals("https://blog.acolyer.org/feed/", feed.feed_url) - assertEquals(10, items!!.size) - val (_, _, _, title, _, _, _, image) = items[0] + assertEquals(10, feed.items!!.size) + val item = feed.items!!.first() assertEquals( "Thou shalt not depend on me: analysing the use of outdated JavaScript libraries on the web", - title, + item.title, ) assertEquals( "http://1.gravatar.com/avatar/a795b4f89a6d096f314fc0a2c80479c1?s=96&d=identicon&r=G", - image, + item.image?.url, ) } @@ -674,23 +669,22 @@ class FeedParserTest : DIAware { @Throws(Exception::class) fun londoner() = runBlocking { - val feed = londoner.use { feedParser.parseFeedResponse(it) } - val (_, _, home_page_url, feed_url, _, _, _, _, _, _, _, _, items) = feed.getOrNull()!! + val feed = londoner.use { feedParser.parseFeedResponse(it) }.getOrNull()!! - assertEquals("http://londonist.com/", home_page_url) - assertEquals("http://londonist.com/feed", feed_url) + assertEquals("http://londonist.com/", feed.home_page_url) + assertEquals("http://londonist.com/feed", feed.feed_url) - assertEquals(40, items!!.size) - val (_, _, _, title, _, _, _, image) = items[0] + assertEquals(40, feed.items!!.size) + val item = feed.items!!.first() assertEquals( "Make The Most Of London's Offerings With Chip", - title, + item.title, ) assertEquals( "http://assets.londonist.com/uploads/2017/06/chip_2.jpg", - image, + item.image?.url, ) } @@ -758,23 +752,22 @@ class FeedParserTest : DIAware { @Throws(Exception::class) fun fz() = runBlocking { - val feed = fz.use { feedParser.parseFeedResponse(it) } - val (_, _, home_page_url, feed_url, _, _, _, _, _, _, _, _, items) = feed.getOrNull()!! + val feed = fz.use { feedParser.parseFeedResponse(it) }.getOrNull()!! - assertEquals("http://www.fz.se/nyheter/", home_page_url) - assertNull(feed_url) + assertEquals("http://www.fz.se/nyheter/", feed.home_page_url) + assertNull(feed.feed_url) - assertEquals(20, items!!.size) - val (_, _, _, title, _, _, _, image) = items[0] + assertEquals(20, feed.items!!.size) + val item = feed.items!!.first() assertEquals( "Nier: Automata bjuder på maffig lanseringstrailer", - title, + item.title, ) assertEquals( "http://d2ihp3fq52ho68.cloudfront.net/YTo2OntzOjI6ImlkIjtpOjEzOTI3OTM7czoxOiJ3IjtpOjUwMDtzOjE6ImgiO2k6OTk5OTtzOjE6ImMiO2k6MDtzOjE6InMiO2k6MDtzOjE6ImsiO3M6NDA6IjU5YjA2YjgyZjkyY2IxZjBiMDZjZmI5MmE3NTk5NjMzMjIyMmU4NGMiO30=", - image, + item.image?.url, ) } @@ -818,7 +811,7 @@ class FeedParserTest : DIAware { assertEquals( "http://cowboyprogrammer.org/hello.jpg&cached=true", - item.image, + item.image?.url, ) assertEquals( "", @@ -839,7 +832,7 @@ class FeedParserTest : DIAware { val text = feed.getOrNull()?.items!!.first() assertEquals( "http://cowboyprogrammer.org/hello.jpg&cached=true", - text.image, + text.image?.url, ) assertEquals( "", @@ -849,7 +842,7 @@ class FeedParserTest : DIAware { val html = feed.getOrNull()?.items!![1] assertEquals( "http://cowboyprogrammer.org/hello.jpg&cached=true", - html.image, + html.image?.url, ) assertEquals( "", @@ -859,7 +852,7 @@ class FeedParserTest : DIAware { val xhtml = feed.getOrNull()?.items!![2] assertEquals( "http://cowboyprogrammer.org/hello.jpg&cached=true", - xhtml.image, + xhtml.image?.url, ) assertTrue("Actual:\n${xhtml.content_html}") { "" in xhtml.content_html!! diff --git a/app/src/test/java/com/nononsenseapps/feeder/util/HtmlUtilsKtTest.kt b/app/src/test/java/com/nononsenseapps/feeder/util/HtmlUtilsKtTest.kt index 26b36c674..1c6594205 100644 --- a/app/src/test/java/com/nononsenseapps/feeder/util/HtmlUtilsKtTest.kt +++ b/app/src/test/java/com/nononsenseapps/feeder/util/HtmlUtilsKtTest.kt @@ -26,7 +26,7 @@ ANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4 val text = "<img src=\"https://imgs.xkcd.com/comics/interstellar_asteroid.png\" title=\"Every time we detect an asteroid from outside the Solar System, we should immediately launch a mission to fling one of our asteroids back in the direction it came from.\" alt=\"Every time we detect an asteroid from outside the Solar System, we should immediately launch a mission to fling one of our asteroids back in the direction it came from.\" />" assertEquals( "https://imgs.xkcd.com/comics/interstellar_asteroid.png", - findFirstImageLinkInHtml(text, null), + findFirstImageLinkInHtml(text, null)?.url, ) } @@ -35,7 +35,7 @@ ANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4 val text = "<img title=\"Every time we detect an asteroid from outside the Solar System, we should immediately launch a mission to fling one of our asteroids back in the direction it came from.\" alt=\"Every time we detect an asteroid from outside the Solar System, we should immediately launch a mission to fling one of our asteroids back in the direction it came from.\" src=\"https://imgs.xkcd.com/comics/interstellar_asteroid.png\" />" assertEquals( "https://imgs.xkcd.com/comics/interstellar_asteroid.png", - findFirstImageLinkInHtml(text, null), + findFirstImageLinkInHtml(text, null)?.url, ) } diff --git a/app/src/test/java/com/nononsenseapps/feeder/util/RomeExtensionsKtTest.kt b/app/src/test/java/com/nononsenseapps/feeder/util/RomeExtensionsKtTest.kt index 96278b54d..d276eb47a 100644 --- a/app/src/test/java/com/nononsenseapps/feeder/util/RomeExtensionsKtTest.kt +++ b/app/src/test/java/com/nononsenseapps/feeder/util/RomeExtensionsKtTest.kt @@ -1,9 +1,11 @@ package com.nononsenseapps.feeder.util -import com.nononsenseapps.jsonfeed.Attachment -import com.nononsenseapps.jsonfeed.Author -import com.nononsenseapps.jsonfeed.Feed -import com.nononsenseapps.jsonfeed.Item +import com.nononsenseapps.feeder.model.ImageFromHTML +import com.nononsenseapps.feeder.model.MediaImage +import com.nononsenseapps.feeder.model.ParsedArticle +import com.nononsenseapps.feeder.model.ParsedAuthor +import com.nononsenseapps.feeder.model.ParsedEnclosure +import com.nononsenseapps.feeder.model.ParsedFeed import com.rometools.modules.mediarss.MediaEntryModule import com.rometools.modules.mediarss.MediaModule import com.rometools.modules.mediarss.types.MediaContent @@ -33,7 +35,7 @@ class RomeExtensionsKtTest { @Test fun author() { assertEquals( - Author(name = "Bobby Jane"), + ParsedAuthor(name = "Bobby Jane"), mockSyndPerson(name = "Bobby Jane").asAuthor(), ) } @@ -42,7 +44,7 @@ class RomeExtensionsKtTest { fun feedLinkButNoLinks() = runBlocking { assertEquals( - Feed(home_page_url = "$baseUrl/homepage", title = "", items = emptyList()), + ParsedFeed(home_page_url = "$baseUrl/homepage", title = "", items = emptyList()), mockSyndFeed(link = "homepage").asFeed(baseUrl), ) } @@ -51,7 +53,7 @@ class RomeExtensionsKtTest { fun feedLinks() = runBlocking { assertEquals( - Feed(home_page_url = "$baseUrl/homepage", title = "", items = emptyList()), + ParsedFeed(home_page_url = "$baseUrl/homepage", title = "", items = emptyList()), mockSyndFeed( links = listOf( @@ -69,14 +71,14 @@ class RomeExtensionsKtTest { fun itemFallsBackToFeedAuthor() = runBlocking { assertEquals( - Feed( - author = Author(name = "bob"), + ParsedFeed( + author = ParsedAuthor(name = "bob"), title = "", items = listOf( - Item( + ParsedArticle( id = "$baseUrl/id", - author = Author(name = "bob"), + author = ParsedAuthor(name = "bob"), content_text = "", url = null, summary = "", @@ -102,14 +104,14 @@ class RomeExtensionsKtTest { val html = " An image " assertEquals( - Item( + ParsedArticle( id = "$baseUrl/id", title = "", content_text = expectedSummary, summary = expectedSummary, url = null, content_html = html, - image = "http://google.com/image.png", + image = ImageFromHTML(url = "http://google.com/image.png", width = null, height = null), attachments = emptyList(), ), mockSyndEntry(uri = "id", description = mockSyndContent(value = html)).asItem(baseUrl), @@ -126,7 +128,7 @@ class RomeExtensionsKtTest { val longText = "$expectedSummary and some additional text" assertEquals( - Item( + ParsedArticle( id = "$baseUrl/id", title = "", content_text = longText, @@ -142,7 +144,7 @@ class RomeExtensionsKtTest { @Test fun itemShortTextShouldNotBeIndexOutOfBounds() { assertEquals( - Item( + ParsedArticle( id = "$baseUrl/id", content_text = "abc", summary = "abc", @@ -158,7 +160,7 @@ class RomeExtensionsKtTest { @Test fun itemLinkButNoLinks() { assertEquals( - Item( + ParsedArticle( id = "$baseUrl/id", content_text = "", summary = "", @@ -178,7 +180,7 @@ class RomeExtensionsKtTest { @Test fun itemLinks() { assertEquals( - Item( + ParsedArticle( id = "$baseUrl/id", content_text = "", summary = "", @@ -202,7 +204,7 @@ class RomeExtensionsKtTest { @Test fun asAttachment() { assertEquals( - Attachment(url = "$baseUrl/uurl", mime_type = "text/html", size_in_bytes = 5), + ParsedEnclosure(url = "$baseUrl/uurl", mime_type = "text/html", size_in_bytes = 5), mockSyndEnclosure(url = "uurl", type = "text/html", length = 5).asAttachment(baseUrl), ) } @@ -210,7 +212,7 @@ class RomeExtensionsKtTest { @Test fun contentTextWithPlainAndOthers() { assertEquals( - Item( + ParsedArticle( id = "$baseUrl/id", content_text = "PLAIN", summary = "PLAIN", @@ -235,7 +237,7 @@ class RomeExtensionsKtTest { @Test fun contentTextWithNullAndOthers() { assertEquals( - Item( + ParsedArticle( id = "$baseUrl/id", content_text = "bah", summary = "bah", @@ -259,7 +261,7 @@ class RomeExtensionsKtTest { @Test fun contentTextWithOthers() { assertEquals( - Item( + ParsedArticle( id = "$baseUrl/id", content_text = "html", summary = "html", @@ -282,7 +284,7 @@ class RomeExtensionsKtTest { @Test fun contentHtmlAtomWithOnlyUnknown() { assertEquals( - Item( + ParsedArticle( id = "$baseUrl/id", title = "", content_text = "foo", @@ -304,7 +306,7 @@ class RomeExtensionsKtTest { @Test fun titleHtmlAtom() { assertEquals( - Item( + ParsedArticle( id = "$baseUrl/id", title = "600 – Email is your electronic memory", content_text = "", @@ -322,7 +324,7 @@ class RomeExtensionsKtTest { @Test fun titleXHtmlAtom() { assertEquals( - Item( + ParsedArticle( id = "$baseUrl/id", title = "600 – Email is your electronic memory", content_text = "", @@ -340,7 +342,7 @@ class RomeExtensionsKtTest { @Test fun titlePlainAtomRss() { assertEquals( - Item( + ParsedArticle( id = "$baseUrl/id", title = "600 – Email is your electronic memory", content_text = "", @@ -358,7 +360,7 @@ class RomeExtensionsKtTest { @Test fun contentHtmlRss() { assertEquals( - Item( + ParsedArticle( id = "$baseUrl/id", content_text = "html", summary = "html", @@ -377,14 +379,14 @@ class RomeExtensionsKtTest { @Test fun thumbnailWithThumbnail() { assertEquals( - Item( + ParsedArticle( id = "$baseUrl/id", title = "", content_text = "", summary = "", attachments = emptyList(), url = null, - image = "$baseUrl/img", + image = MediaImage(url = "$baseUrl/img", width = 0, height = 0), ), mockSyndEntry( uri = "id", @@ -396,7 +398,7 @@ class RomeExtensionsKtTest { @Test fun asItemDiscardsInlineBase64ImagesAsThumbnails() { assertEquals( - Item( + ParsedArticle( id = "$baseUrl/id", title = "", content_text = "", @@ -426,14 +428,14 @@ class RomeExtensionsKtTest { @Test fun thumbnailWithContent() { assertEquals( - Item( + ParsedArticle( id = "$baseUrl/id", title = "", content_text = "", summary = "", attachments = emptyList(), url = null, - image = "$baseUrl/img", + image = MediaImage(url = "$baseUrl/img", width = 0, height = 0), ), mockSyndEntry( uri = "id", @@ -461,7 +463,7 @@ class RomeExtensionsKtTest { assertEquals( "https://o.aolcdn.com/images/dims?crop=1200%2C627%2C0%2C0&quality=85&format=jpg&resize=1600%2C836&image_uri=https%3A%2F%2Fs.yimg.com%2Fos%2Fcreatr-uploaded-images%2F2019-03%2Ffa057c20-5050-11e9-bfef-d1614983d7cc&client=a1acac3e1b3290917d92&signature=351348aa11c53a569d5ad40f3a7ef697471b645a", - item.image, + item.image?.url, ) } @@ -484,7 +486,7 @@ class RomeExtensionsKtTest { assertEquals( "https://o.aolcdn.com/images/dims?crop=1200%2C627%2C0%2C0&quality=85&format=jpg&resize=1600%2C836&image_uri=https%3A%2F%2Fs.yimg.com%2Fos%2Fcreatr-uploaded-images%2F2019-03%2Ffa057c20-5050-11e9-bfef-d1614983d7cc&client=a1acac3e1b3290917d92&signature=351348aa11c53a569d5ad40f3a7ef697471b645a", - item.image, + item.image?.url, ) } @@ -507,7 +509,7 @@ class RomeExtensionsKtTest { assertEquals( "https://o.aolcdn.com/images/dims?crop=1200%2C627%2C0%2C0&quality=85&format=jpg&resize=1600%2C836&image_uri=https%3A%2F%2Fs.yimg.com%2Fos%2Fcreatr-uploaded-images%2F2019-03%2Ffa057c20-5050-11e9-bfef-d1614983d7cc&client=a1acac3e1b3290917d92&signature=351348aa11c53a569d5ad40f3a7ef697471b645a", - item.image, + item.image?.url, ) } @@ -527,7 +529,7 @@ class RomeExtensionsKtTest { assertEquals( "http://foo/bar.png", - item.image, + item.image?.url, ) } @@ -537,7 +539,7 @@ class RomeExtensionsKtTest { val romeDate = Date(ZonedDateTime.parse("2017-11-15T22:36:36+00:00").toInstant().toEpochMilli()) val dateTime = ZonedDateTime.ofInstant(Instant.ofEpochMilli(romeDate.time), ZoneOffset.systemDefault()) assertEquals( - Item( + ParsedArticle( id = "$baseUrl/id", title = "", content_text = "", @@ -559,7 +561,7 @@ class RomeExtensionsKtTest { val romeDate = Date(ZonedDateTime.parse("2017-11-15T22:36:36+00:00").toInstant().toEpochMilli()) val dateTime = ZonedDateTime.ofInstant(Instant.ofEpochMilli(romeDate.time), ZoneOffset.systemDefault()) assertEquals( - Item( + ParsedArticle( id = "$baseUrl/id", title = "", content_text = "", @@ -584,7 +586,7 @@ class RomeExtensionsKtTest { val pubDate = ZonedDateTime.ofInstant(Instant.ofEpochMilli(romePubDate.time), ZoneOffset.systemDefault()) val modDate = ZonedDateTime.ofInstant(Instant.ofEpochMilli(romeModDate.time), ZoneOffset.systemDefault()) assertEquals( - Item( + ParsedArticle( id = "$baseUrl/id", title = "", content_text = "",