summaryrefslogtreecommitdiff
path: root/app/src/main/java/org/terst/feedreader/data/AppDatabase.kt
blob: da68d3d5c61e967992d54b7dcbb2307f8b0ec14d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
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 = 7,
    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 <guid>/<id> 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))
                }
            }
        }

        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).build().also { instance = it }
        }
    }
}