package org.terst.feedreader.data import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.sqlite.db.SupportSQLiteDatabase import androidx.room.migration.Migration @Database( entities = [FeedEntity::class, ArticleEntity::class, FilterRuleEntity::class], version = 8, exportSchema = false ) abstract class AppDatabase : RoomDatabase() { abstract fun feedDao(): FeedDao abstract fun articleDao(): ArticleDao abstract fun filterRuleDao(): FilterRuleDao companion object { @Volatile private var instance: AppDatabase? = null // v2: added ArticleEntity.imageUrl (thumbnail support). Real migrations, // not fallbackToDestructiveMigration, since this app already has real // subscriptions/read-state on-device during active development -- // wiping the DB on every schema tweak would mean re-importing OPML // and losing read history each time. private val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE articles ADD COLUMN imageUrl TEXT") } } // v3: added FeedEntity.readerModeDisabled (per-feed reader-mode opt-out, paired with // an auto-extract-on-thin-content heuristic). Superseded by v4 below. private val MIGRATION_2_3 = object : Migration(2, 3) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE feeds ADD COLUMN readerModeDisabled INTEGER NOT NULL DEFAULT 0") } } // v4: replaced readerModeDisabled with readerModeEnabled -- reader mode is no longer // ever auto-triggered by a content-length heuristic (that's what kept "auto-switching" // kottke articles even after the v3 opt-out); it's now a plain per-feed opt-in, set // only by explicitly tapping the reader-mode toggle. Starts everyone fresh at off // rather than trying to translate the old opt-out semantics, which don't map cleanly // onto "opt in". A plain ADD COLUMN + leaving the old one in place (as v1->v2 and // v2->v3 do) crashes here: Room's post-migration validation requires the table's // columns to match FeedEntity's fields EXACTLY, and readerModeDisabled isn't a field // anymore -- confirmed live ("Migration didn't properly handle: feeds"). SQLite has no // DROP COLUMN on the versions bundled with older Android, so this rebuilds the table // instead: create with the new shape, copy the columns that still exist, swap in. private val MIGRATION_3_4 = object : Migration(3, 4) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL( """ CREATE TABLE feeds_new ( id TEXT NOT NULL PRIMARY KEY, title TEXT NOT NULL, siteUrl TEXT, lastFetchedAt INTEGER, etag TEXT, lastModified TEXT, lastError TEXT, readerModeEnabled INTEGER NOT NULL DEFAULT 0 ) """.trimIndent() ) db.execSQL( """ INSERT INTO feeds_new (id, title, siteUrl, lastFetchedAt, etag, lastModified, lastError) SELECT id, title, siteUrl, lastFetchedAt, etag, lastModified, lastError FROM feeds """.trimIndent() ) db.execSQL("DROP TABLE feeds") db.execSQL("ALTER TABLE feeds_new RENAME TO feeds") } } // v5: a feed with a literal empty / element (confirmed live: a Mother // Jones entry) produced an ArticleEntity with id = "" -- FeedParser fell back to // itemLink only when the guid was null, not when it was blank. An empty-string // primary key broke Navigation Compose's route matching and crashed on tap. The // parser is fixed for future fetches; this cleans out any row already on-device // with the bad id so it doesn't keep crashing. It'll come back with a real, // hash-based id on the next fetch of that feed. private val MIGRATION_4_5 = object : Migration(4, 5) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("DELETE FROM articles WHERE trim(id) = ''") } } // v6: added FeedEntity.folder (OPML category, for grouping the feed list). A plain // ADD COLUMN is safe here (unlike v3->v4) since folder is a genuinely new field with // no prior column it needs to replace/rebuild around. private val MIGRATION_5_6 = object : Migration(5, 6) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE feeds ADD COLUMN folder TEXT") } } // v7: this app now upgrades http:// feed URLs to https:// at add/import time (see // AddFeedScreen.preferHttps), but that only ever runs on the way IN -- feeds already // subscribed to before this change stay http:// forever otherwise. Android blocks // cleartext by default (targetSdk 28+), so every one of these was failing every fetch // ("CLEARTEXT communication ... not permitted"). Checked live (curl) which of this // user's http feeds actually serve valid content over https and rewrite just those -- // three hosts genuinely have no usable HTTPS (no TLS listener at all, a broken TLS // handshake, or a certificate that doesn't match the hostname) and are deliberately // left alone, since switching them would just trade one permanent failure for another. private val MIGRATION_6_7 = object : Migration(6, 7) { override fun migrate(db: SupportSQLiteDatabase) { val httpToHttps = mapOf( "http://blog.cryptographyengineering.com/feeds/posts/default" to "https://blog.cryptographyengineering.com/feeds/posts/default", "http://mybiasedcoin.blogspot.com/feeds/posts/default?alt=rss" to "https://mybiasedcoin.blogspot.com/feeds/posts/default?alt=rss", "http://processalgebra.blogspot.com/feeds/posts/default?alt=rss" to "https://processalgebra.blogspot.com/feeds/posts/default?alt=rss", "http://www.rowetel.com/blog/?feed=rss2" to "https://www.rowetel.com/blog/?feed=rss2", "http://www.thecodelesscode.com/rss" to "https://www.thecodelesscode.com/rss", // This one's own https endpoint redirects through an insecure (http) // feedburner hop before landing on real content -- point straight at // feedburner's own https endpoint instead of a URL that would still fail // cleartext-blocked partway through the redirect chain. "http://www.divingforpearlsblog.com/feeds/posts/default" to "https://feeds.feedburner.com/blogspot/sjomU" ) for ((oldUrl, newUrl) in httpToHttps) { db.execSQL("UPDATE articles SET feedId = ? WHERE feedId = ?", arrayOf(newUrl, oldUrl)) db.execSQL("UPDATE feeds SET id = ? WHERE id = ?", arrayOf(newUrl, oldUrl)) } } } // v8: one more http feed turned out to have a real https home after all -- // blog.securitymouse.com's own TLS handshake is broken, but it's actually a Blogger // blog under the hood (its feed XML is Blogger's own Atom format, "Server: GSE" on // its homepage response), and Blogger blogs are always reachable at a stable, // always-https URL keyed by blog ID regardless of a custom domain's own DNS/TLS setup. // The other two hosts left over from v7 (bit-player.org, webpolicy.org) genuinely have // no https path forward: webpolicy.org's feed itself is dead (last post 2015, long // before this check), and bit-player.org's site was rebuilt on a static generator that // no longer appears to expose a feed at any path (checked /feed, /feed/, /feed.xml, // /rss, /atom.xml, and the homepage's own -- nothing). private val MIGRATION_7_8 = object : Migration(7, 8) { override fun migrate(db: SupportSQLiteDatabase) { val oldUrl = "http://blog.securitymouse.com/feeds/posts/default" val newUrl = "https://www.blogger.com/feeds/5803873077727212399/posts/default" db.execSQL("UPDATE articles SET feedId = ? WHERE feedId = ?", arrayOf(newUrl, oldUrl)) db.execSQL("UPDATE feeds SET id = ? WHERE id = ?", arrayOf(newUrl, oldUrl)) } } fun get(context: Context): AppDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, "feedreader.db" ).addMigrations( MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8 ).build().also { instance = it } } } }