diff --git a/app/schemas/org.wikipedia.database.AppDatabase/26.json b/app/schemas/org.wikipedia.database.AppDatabase/26.json index 0a0a2f3a0cb..be8196e5912 100644 --- a/app/schemas/org.wikipedia.database.AppDatabase/26.json +++ b/app/schemas/org.wikipedia.database.AppDatabase/26.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 26, - "identityHash": "16a25d36f8cbc3a8743699b56a550c06", + "identityHash": "d042bcf4c06c8727f99ac4c827e5a691", "entities": [ { "tableName": "HistoryEntry", @@ -536,12 +536,44 @@ }, "indices": [], "foreignKeys": [] + }, + { + "tableName": "Tab", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `backStack` TEXT NOT NULL, `backStackPosition` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "backStack", + "columnName": "backStack", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "backStackPosition", + "columnName": "backStackPosition", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] } ], "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, '16a25d36f8cbc3a8743699b56a550c06')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd042bcf4c06c8727f99ac4c827e5a691')" ] } } \ No newline at end of file diff --git a/app/schemas/org.wikipedia.database.AppDatabase/27.json b/app/schemas/org.wikipedia.database.AppDatabase/27.json new file mode 100644 index 00000000000..cf49a799d86 --- /dev/null +++ b/app/schemas/org.wikipedia.database.AppDatabase/27.json @@ -0,0 +1,579 @@ +{ + "formatVersion": 1, + "database": { + "version": 27, + "identityHash": "d042bcf4c06c8727f99ac4c827e5a691", + "entities": [ + { + "tableName": "HistoryEntry", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authority` TEXT NOT NULL, `lang` TEXT NOT NULL, `apiTitle` TEXT NOT NULL, `displayTitle` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `namespace` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `source` INTEGER NOT NULL, `timeSpentSec` INTEGER NOT NULL, `description` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "authority", + "columnName": "authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lang", + "columnName": "lang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiTitle", + "columnName": "apiTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayTitle", + "columnName": "displayTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "namespace", + "columnName": "namespace", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeSpentSec", + "columnName": "timeSpentSec", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PageImage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`lang` TEXT NOT NULL, `namespace` TEXT NOT NULL, `apiTitle` TEXT NOT NULL, `imageName` TEXT, PRIMARY KEY(`lang`, `namespace`, `apiTitle`))", + "fields": [ + { + "fieldPath": "lang", + "columnName": "lang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "namespace", + "columnName": "namespace", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiTitle", + "columnName": "apiTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageName", + "columnName": "imageName", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "lang", + "namespace", + "apiTitle" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "RecentSearch", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`text` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`text`))", + "fields": [ + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "text" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TalkPageSeen", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sha` TEXT NOT NULL, PRIMARY KEY(`sha`))", + "fields": [ + { + "fieldPath": "sha", + "columnName": "sha", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sha" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "EditSummary", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`summary` TEXT NOT NULL, `lastUsed` INTEGER NOT NULL, PRIMARY KEY(`summary`))", + "fields": [ + { + "fieldPath": "summary", + "columnName": "summary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "summary" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "OfflineObject", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `lang` TEXT NOT NULL, `path` TEXT NOT NULL, `status` INTEGER NOT NULL, `usedByStr` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lang", + "columnName": "lang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedByStr", + "columnName": "usedByStr", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ReadingList", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`listTitle` TEXT NOT NULL, `description` TEXT, `mtime` INTEGER NOT NULL, `atime` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sizeBytes` INTEGER NOT NULL, `dirty` INTEGER NOT NULL, `remoteId` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "listTitle", + "columnName": "listTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mtime", + "columnName": "mtime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "atime", + "columnName": "atime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sizeBytes", + "columnName": "sizeBytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dirty", + "columnName": "dirty", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteId", + "columnName": "remoteId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ReadingListPage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`wiki` TEXT NOT NULL, `namespace` INTEGER NOT NULL, `displayTitle` TEXT NOT NULL, `apiTitle` TEXT NOT NULL, `description` TEXT, `thumbUrl` TEXT, `listId` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mtime` INTEGER NOT NULL, `atime` INTEGER NOT NULL, `offline` INTEGER NOT NULL, `status` INTEGER NOT NULL, `sizeBytes` INTEGER NOT NULL, `lang` TEXT NOT NULL, `revId` INTEGER NOT NULL, `remoteId` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "wiki", + "columnName": "wiki", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "namespace", + "columnName": "namespace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayTitle", + "columnName": "displayTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiTitle", + "columnName": "apiTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbUrl", + "columnName": "thumbUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "listId", + "columnName": "listId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mtime", + "columnName": "mtime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "atime", + "columnName": "atime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "offline", + "columnName": "offline", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sizeBytes", + "columnName": "sizeBytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lang", + "columnName": "lang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revId", + "columnName": "revId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteId", + "columnName": "remoteId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Notification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `wiki` TEXT NOT NULL, `read` TEXT, `category` TEXT NOT NULL, `type` TEXT NOT NULL, `revid` INTEGER NOT NULL, `title` TEXT, `agent` TEXT, `timestamp` TEXT, `contents` TEXT, PRIMARY KEY(`id`, `wiki`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wiki", + "columnName": "wiki", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revid", + "columnName": "revid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "agent", + "columnName": "agent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contents", + "columnName": "contents", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "wiki" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TalkTemplate", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` INTEGER NOT NULL, `order` INTEGER NOT NULL, `title` TEXT NOT NULL, `subject` TEXT NOT NULL, `message` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Tab", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `backStack` TEXT NOT NULL, `backStackPosition` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "backStack", + "columnName": "backStack", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "backStackPosition", + "columnName": "backStackPosition", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "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, 'd042bcf4c06c8727f99ac4c827e5a691')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/org/wikipedia/WikipediaApp.kt b/app/src/main/java/org/wikipedia/WikipediaApp.kt index a446dd71ab8..d4e81467e55 100644 --- a/app/src/main/java/org/wikipedia/WikipediaApp.kt +++ b/app/src/main/java/org/wikipedia/WikipediaApp.kt @@ -9,8 +9,11 @@ import android.speech.RecognizerIntent import android.webkit.WebView import androidx.appcompat.app.AppCompatDelegate import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.wikipedia.analytics.eventplatform.AppSessionEvent import org.wikipedia.analytics.eventplatform.EventPlatformClient import org.wikipedia.appshortcuts.AppShortcuts @@ -147,7 +150,12 @@ class WikipediaApp : Application() { currentTheme = unmarshalTheme(Prefs.currentThemeId) - initTabs() + CoroutineScope(Dispatchers.IO).launch(CoroutineExceptionHandler { _, t -> + L.e(t) + }) { + initTabs() + } + enableWebViewDebugging() registerActivityLifecycleCallbacks(activityLifecycleHandler) registerComponentCallbacks(activityLifecycleHandler) @@ -207,15 +215,6 @@ class WikipediaApp : Application() { // TODO: send exception to custom crash reporting system } - fun commitTabState() { - if (tabList.isEmpty()) { - Prefs.clearTabs() - initTabs() - } else { - Prefs.tabs = tabList - } - } - /** * Gets the current size of the app's font. This is given as a device-specific size (not "sp"), * and can be passed directly to setTextSize() functions. @@ -269,12 +268,42 @@ class WikipediaApp : Application() { return result } - private fun initTabs() { - if (Prefs.hasTabs) { - tabList.addAll(Prefs.tabs) + // TODO: remove on 2026-02-01 + private suspend fun migrateTabsToDatabase() { + withContext(Dispatchers.IO) { + if (Prefs.tabs.isEmpty() || AppDatabase.instance.tabDao().hasTabs()) { + return@withContext + } + AppDatabase.instance.tabDao().insertTabs(Prefs.tabs) + + // TODO: enable this on 2026-02-01 + // Prefs.clearTabs() } - if (tabList.isEmpty()) { - tabList.add(Tab()) + } + + private suspend fun initTabs() { + withContext(Dispatchers.IO) { + migrateTabsToDatabase() + if (AppDatabase.instance.tabDao().hasTabs()) { + tabList.addAll(AppDatabase.instance.tabDao().getTabs()) + } + if (tabList.isEmpty()) { + tabList.add(Tab()) + } + } + } + + fun commitTabState() { + CoroutineScope(Dispatchers.IO).launch(CoroutineExceptionHandler { _, t -> + L.e(t) + }) { + if (tabList.isEmpty()) { + AppDatabase.instance.tabDao().deleteAll() + initTabs() + } else { + AppDatabase.instance.tabDao().deleteAll() + AppDatabase.instance.tabDao().insertTabs(tabList) + } } } diff --git a/app/src/main/java/org/wikipedia/database/AppDatabase.kt b/app/src/main/java/org/wikipedia/database/AppDatabase.kt index 12ef4ecc6e2..b23a7e1555e 100644 --- a/app/src/main/java/org/wikipedia/database/AppDatabase.kt +++ b/app/src/main/java/org/wikipedia/database/AppDatabase.kt @@ -16,6 +16,8 @@ import org.wikipedia.notifications.db.Notification import org.wikipedia.notifications.db.NotificationDao import org.wikipedia.offline.db.OfflineObject import org.wikipedia.offline.db.OfflineObjectDao +import org.wikipedia.page.tabs.Tab +import org.wikipedia.page.tabs.TabDao import org.wikipedia.pageimages.db.PageImage import org.wikipedia.pageimages.db.PageImageDao import org.wikipedia.readinglist.database.ReadingList @@ -31,7 +33,7 @@ import org.wikipedia.talk.db.TalkTemplate import org.wikipedia.talk.db.TalkTemplateDao const val DATABASE_NAME = "wikipedia.db" -const val DATABASE_VERSION = 26 +const val DATABASE_VERSION = 27 @Database( entities = [ @@ -44,7 +46,8 @@ const val DATABASE_VERSION = 26 ReadingList::class, ReadingListPage::class, Notification::class, - TalkTemplate::class + TalkTemplate::class, + Tab::class ], version = DATABASE_VERSION ) @@ -52,7 +55,8 @@ const val DATABASE_VERSION = 26 DateTypeConverter::class, WikiSiteTypeConverter::class, NamespaceTypeConverter::class, - NotificationTypeConverters::class + NotificationTypeConverters::class, + PageBackStackItemTypeConverter::class ) abstract class AppDatabase : RoomDatabase() { @@ -67,127 +71,142 @@ abstract class AppDatabase : RoomDatabase() { abstract fun readingListPageDao(): ReadingListPageDao abstract fun notificationDao(): NotificationDao abstract fun talkTemplateDao(): TalkTemplateDao + abstract fun tabDao(): TabDao companion object { val MIGRATION_19_20 = object : Migration(19, 20) { - override fun migrate(database: SupportSQLiteDatabase) { + override fun migrate(db: SupportSQLiteDatabase) { // introduced Offline Object table } } val MIGRATION_20_21 = object : Migration(20, 21) { - override fun migrate(database: SupportSQLiteDatabase) { + override fun migrate(db: SupportSQLiteDatabase) { // introduced Talk Page Seen table } } val MIGRATION_21_22 = object : Migration(21, 22) { - override fun migrate(database: SupportSQLiteDatabase) {} + override fun migrate(db: SupportSQLiteDatabase) {} } val MIGRATION_22_23 = object : Migration(22, 23) { - override fun migrate(database: SupportSQLiteDatabase) { + override fun migrate(db: SupportSQLiteDatabase) { val defaultLang = WikipediaApp.instance.appOrSystemLanguageCode val defaultAuthority = WikipediaApp.instance.wikiSite.authority() val defaultTitle = MainPageNameData.valueFor(defaultLang) // convert Recent Searches table - database.execSQL("CREATE TABLE IF NOT EXISTS `RecentSearch` (`text` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`text`))") + db.execSQL("CREATE TABLE IF NOT EXISTS `RecentSearch` (`text` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`text`))") try { - database.execSQL("INSERT OR REPLACE INTO RecentSearch (text, timestamp) SELECT text, timestamp FROM recentsearches") + db.execSQL("INSERT OR REPLACE INTO RecentSearch (text, timestamp) SELECT text, timestamp FROM recentsearches") } catch (e: Exception) { // ignore further errors } - database.execSQL("DROP TABLE IF EXISTS recentsearches") + db.execSQL("DROP TABLE IF EXISTS recentsearches") // convert Talk Pages Seen table - database.execSQL("CREATE TABLE IF NOT EXISTS `TalkPageSeen_temp` (`sha` TEXT NOT NULL, PRIMARY KEY(`sha`))") + db.execSQL("CREATE TABLE IF NOT EXISTS `TalkPageSeen_temp` (`sha` TEXT NOT NULL, PRIMARY KEY(`sha`))") try { - database.query("SELECT * FROM sqlite_master WHERE type='table' AND name='talkpageseen'").use { + db.query("SELECT * FROM sqlite_master WHERE type='table' AND name='talkpageseen'").use { if (it.count > 0) { - database.execSQL("INSERT OR REPLACE INTO TalkPageSeen_temp (sha) SELECT sha FROM talkpageseen") - database.execSQL("DROP TABLE talkpageseen") + db.execSQL("INSERT OR REPLACE INTO TalkPageSeen_temp (sha) SELECT sha FROM talkpageseen") + db.execSQL("DROP TABLE talkpageseen") } } } catch (e: Exception) { // ignore further errors } - database.execSQL("ALTER TABLE TalkPageSeen_temp RENAME TO TalkPageSeen") + db.execSQL("ALTER TABLE TalkPageSeen_temp RENAME TO TalkPageSeen") // convert Edit Summaries table - database.execSQL("CREATE TABLE IF NOT EXISTS `EditSummary` (`summary` TEXT NOT NULL, `lastUsed` INTEGER NOT NULL, PRIMARY KEY(`summary`))") + db.execSQL("CREATE TABLE IF NOT EXISTS `EditSummary` (`summary` TEXT NOT NULL, `lastUsed` INTEGER NOT NULL, PRIMARY KEY(`summary`))") try { - database.execSQL("INSERT OR REPLACE INTO EditSummary (summary, lastUsed) SELECT summary, lastUsed FROM editsummaries") + db.execSQL("INSERT OR REPLACE INTO EditSummary (summary, lastUsed) SELECT summary, lastUsed FROM editsummaries") } catch (e: Exception) { // ignore further errors } - database.execSQL("DROP TABLE IF EXISTS editsummaries") + db.execSQL("DROP TABLE IF EXISTS editsummaries") // convert Offline Objects table - database.execSQL("CREATE TABLE IF NOT EXISTS `OfflineObject_temp` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `lang` TEXT NOT NULL, `path` TEXT NOT NULL, `status` INTEGER NOT NULL, `usedByStr` TEXT NOT NULL)") + db.execSQL("CREATE TABLE IF NOT EXISTS `OfflineObject_temp` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `lang` TEXT NOT NULL, `path` TEXT NOT NULL, `status` INTEGER NOT NULL, `usedByStr` TEXT NOT NULL)") try { - database.query("SELECT * FROM sqlite_master WHERE type='table' AND name='offlineobject'").use { + db.query("SELECT * FROM sqlite_master WHERE type='table' AND name='offlineobject'").use { if (it.count > 0) { - database.execSQL("INSERT INTO OfflineObject_temp (id, url, lang, path, status, usedByStr) SELECT _id, url, lang, path, status, usedby FROM offlineobject") - database.execSQL("DROP TABLE offlineobject") + db.execSQL("INSERT INTO OfflineObject_temp (id, url, lang, path, status, usedByStr) SELECT _id, url, lang, path, status, usedby FROM offlineobject") + db.execSQL("DROP TABLE offlineobject") } } } catch (e: Exception) { // ignore further errors } - database.execSQL("ALTER TABLE OfflineObject_temp RENAME TO OfflineObject") + db.execSQL("ALTER TABLE OfflineObject_temp RENAME TO OfflineObject") // Delete vestigial Reading List tables that might have been left over from very old DB versions. - database.execSQL("DROP TABLE IF EXISTS readinglist") - database.execSQL("DROP TABLE IF EXISTS readinglistpage") + db.execSQL("DROP TABLE IF EXISTS readinglist") + db.execSQL("DROP TABLE IF EXISTS readinglistpage") // convert Reading List tables - database.execSQL("CREATE TABLE IF NOT EXISTS `ReadingList` (`listTitle` TEXT NOT NULL, `description` TEXT, `mtime` INTEGER NOT NULL, `atime` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sizeBytes` INTEGER NOT NULL, `dirty` INTEGER NOT NULL, `remoteId` INTEGER NOT NULL)") - database.execSQL("CREATE TABLE IF NOT EXISTS `ReadingListPage` (`wiki` TEXT NOT NULL, `namespace` INTEGER NOT NULL, `displayTitle` TEXT NOT NULL, `apiTitle` TEXT NOT NULL, `description` TEXT, `thumbUrl` TEXT, `listId` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mtime` INTEGER NOT NULL, `atime` INTEGER NOT NULL, `offline` INTEGER NOT NULL, `status` INTEGER NOT NULL, `sizeBytes` INTEGER NOT NULL, `lang` TEXT NOT NULL, `revId` INTEGER NOT NULL, `remoteId` INTEGER NOT NULL)") + db.execSQL("CREATE TABLE IF NOT EXISTS `ReadingList` (`listTitle` TEXT NOT NULL, `description` TEXT, `mtime` INTEGER NOT NULL, `atime` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sizeBytes` INTEGER NOT NULL, `dirty` INTEGER NOT NULL, `remoteId` INTEGER NOT NULL)") + db.execSQL("CREATE TABLE IF NOT EXISTS `ReadingListPage` (`wiki` TEXT NOT NULL, `namespace` INTEGER NOT NULL, `displayTitle` TEXT NOT NULL, `apiTitle` TEXT NOT NULL, `description` TEXT, `thumbUrl` TEXT, `listId` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mtime` INTEGER NOT NULL, `atime` INTEGER NOT NULL, `offline` INTEGER NOT NULL, `status` INTEGER NOT NULL, `sizeBytes` INTEGER NOT NULL, `lang` TEXT NOT NULL, `revId` INTEGER NOT NULL, `remoteId` INTEGER NOT NULL)") try { - database.execSQL("INSERT INTO ReadingList (id, listTitle, description, mtime, atime, sizeBytes, dirty, remoteId) SELECT _id, COALESCE(readingListTitle,''), readingListDescription, readingListMtime, readingListAtime, readingListSizeBytes, readingListDirty, readingListRemoteId FROM localreadinglist") - database.execSQL("INSERT INTO ReadingListPage (id, wiki, namespace, displayTitle, apiTitle, description, thumbUrl, listId, mtime, atime, offline, status, sizeBytes, lang, revId, remoteId) SELECT _id, site, namespace, title, COALESCE(apiTitle,title), description, thumbnailUrl, listId, mtime, atime, offline, status, sizeBytes, lang, revId, remoteId FROM localreadinglistpage") + db.execSQL("INSERT INTO ReadingList (id, listTitle, description, mtime, atime, sizeBytes, dirty, remoteId) SELECT _id, COALESCE(readingListTitle,''), readingListDescription, readingListMtime, readingListAtime, readingListSizeBytes, readingListDirty, readingListRemoteId FROM localreadinglist") + db.execSQL("INSERT INTO ReadingListPage (id, wiki, namespace, displayTitle, apiTitle, description, thumbUrl, listId, mtime, atime, offline, status, sizeBytes, lang, revId, remoteId) SELECT _id, site, namespace, title, COALESCE(apiTitle,title), description, thumbnailUrl, listId, mtime, atime, offline, status, sizeBytes, lang, revId, remoteId FROM localreadinglistpage") } catch (e: Exception) { // ignore further errors } - database.execSQL("DROP TABLE IF EXISTS localreadinglist") - database.execSQL("DROP TABLE IF EXISTS localreadinglistpage") + db.execSQL("DROP TABLE IF EXISTS localreadinglist") + db.execSQL("DROP TABLE IF EXISTS localreadinglistpage") // convert History table - database.execSQL("CREATE TABLE IF NOT EXISTS `HistoryEntry` (`authority` TEXT NOT NULL, `lang` TEXT NOT NULL, `apiTitle` TEXT NOT NULL, `displayTitle` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `namespace` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `source` INTEGER NOT NULL, `timeSpentSec` INTEGER NOT NULL)") + db.execSQL("CREATE TABLE IF NOT EXISTS `HistoryEntry` (`authority` TEXT NOT NULL, `lang` TEXT NOT NULL, `apiTitle` TEXT NOT NULL, `displayTitle` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `namespace` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `source` INTEGER NOT NULL, `timeSpentSec` INTEGER NOT NULL)") try { - database.execSQL("INSERT INTO HistoryEntry (id, authority, lang, apiTitle, displayTitle, namespace, source, timestamp, timeSpentSec) SELECT _id, COALESCE(site,'$defaultAuthority'), COALESCE(lang,'$defaultLang'), COALESCE(title,'$defaultTitle'), COALESCE(displayTitle,''), COALESCE(namespace,''), COALESCE(source,2), COALESCE(timestamp,0), COALESCE(timeSpent,0) FROM history") + db.execSQL("INSERT INTO HistoryEntry (id, authority, lang, apiTitle, displayTitle, namespace, source, timestamp, timeSpentSec) SELECT _id, COALESCE(site,'$defaultAuthority'), COALESCE(lang,'$defaultLang'), COALESCE(title,'$defaultTitle'), COALESCE(displayTitle,''), COALESCE(namespace,''), COALESCE(source,2), COALESCE(timestamp,0), COALESCE(timeSpent,0) FROM history") } catch (e: Exception) { // ignore further errors } - database.execSQL("DROP TABLE IF EXISTS history") + db.execSQL("DROP TABLE IF EXISTS history") // convert Page Images table - database.execSQL("CREATE TABLE IF NOT EXISTS `PageImage` (`lang` TEXT NOT NULL, `namespace` TEXT NOT NULL, `apiTitle` TEXT NOT NULL, `imageName` TEXT, PRIMARY KEY(`lang`, `namespace`, `apiTitle`))") + db.execSQL("CREATE TABLE IF NOT EXISTS `PageImage` (`lang` TEXT NOT NULL, `namespace` TEXT NOT NULL, `apiTitle` TEXT NOT NULL, `imageName` TEXT, PRIMARY KEY(`lang`, `namespace`, `apiTitle`))") try { - database.execSQL("INSERT OR REPLACE INTO PageImage (lang, namespace, apiTitle, imageName) SELECT COALESCE(lang,'$defaultLang'), COALESCE(namespace,''), COALESCE(title,'$defaultTitle'), imageName FROM pageimages") + db.execSQL("INSERT OR REPLACE INTO PageImage (lang, namespace, apiTitle, imageName) SELECT COALESCE(lang,'$defaultLang'), COALESCE(namespace,''), COALESCE(title,'$defaultTitle'), imageName FROM pageimages") } catch (e: Exception) { // ignore further errors } - database.execSQL("DROP TABLE pageimages") + db.execSQL("DROP TABLE pageimages") } } val MIGRATION_23_24 = object : Migration(23, 24) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("CREATE TABLE IF NOT EXISTS `Notification` (`id` INTEGER NOT NULL, `wiki` TEXT NOT NULL, `read` TEXT, `category` TEXT NOT NULL, `type` TEXT NOT NULL, `revid` INTEGER NOT NULL, `title` TEXT, `agent` TEXT, `timestamp` TEXT, `contents` TEXT, PRIMARY KEY(`id`, `wiki`))") + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("CREATE TABLE IF NOT EXISTS `Notification` (`id` INTEGER NOT NULL, `wiki` TEXT NOT NULL, `read` TEXT, `category` TEXT NOT NULL, `type` TEXT NOT NULL, `revid` INTEGER NOT NULL, `title` TEXT, `agent` TEXT, `timestamp` TEXT, `contents` TEXT, PRIMARY KEY(`id`, `wiki`))") } } val MIGRATION_24_25 = object : Migration(24, 25) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("CREATE TABLE IF NOT EXISTS `TalkTemplate` (`id` INTEGER NOT NULL, `type` INTEGER NOT NULL, `order` INTEGER NOT NULL, `title` TEXT NOT NULL, `subject` TEXT NOT NULL, `message` TEXT NOT NULL, PRIMARY KEY(`id`))") + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("CREATE TABLE IF NOT EXISTS `TalkTemplate` (`id` INTEGER NOT NULL, `type` INTEGER NOT NULL, `order` INTEGER NOT NULL, `title` TEXT NOT NULL, `subject` TEXT NOT NULL, `message` TEXT NOT NULL, PRIMARY KEY(`id`))") } } val MIGRATION_25_26 = object : Migration(25, 26) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("ALTER TABLE HistoryEntry ADD COLUMN description TEXT NOT NULL DEFAULT ''") + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE HistoryEntry ADD COLUMN description TEXT NOT NULL DEFAULT ''") + } + } + val MIGRATION_26_27 = object : Migration(26, 27) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("CREATE TABLE IF NOT EXISTS `Tab` (`id` INTEGER NOT NULL, `backStack` TEXT NOT NULL, `backStackPosition` INTEGER NOT NULL, PRIMARY KEY(`id`))") } } val instance: AppDatabase by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { Room.databaseBuilder(WikipediaApp.instance, AppDatabase::class.java, DATABASE_NAME) - .addMigrations(MIGRATION_19_20, MIGRATION_20_21, MIGRATION_21_22, MIGRATION_22_23, MIGRATION_23_24, MIGRATION_24_25, MIGRATION_25_26) + .addMigrations( + MIGRATION_19_20, + MIGRATION_20_21, + MIGRATION_21_22, + MIGRATION_22_23, + MIGRATION_23_24, + MIGRATION_24_25, + MIGRATION_25_26, + MIGRATION_26_27 + ) .allowMainThreadQueries() // TODO: remove after migration .fallbackToDestructiveMigration() .build() diff --git a/app/src/main/java/org/wikipedia/database/PageBackStackItemTypeConverter.kt b/app/src/main/java/org/wikipedia/database/PageBackStackItemTypeConverter.kt new file mode 100644 index 00000000000..0b2f8be05d4 --- /dev/null +++ b/app/src/main/java/org/wikipedia/database/PageBackStackItemTypeConverter.kt @@ -0,0 +1,19 @@ +package org.wikipedia.database + +import androidx.room.TypeConverter +import org.wikipedia.json.JsonUtil +import org.wikipedia.page.PageBackStackItem + +class PageBackStackItemTypeConverter { + @TypeConverter + fun fromPageBackStackItem(backStacks: MutableList): String? { + return JsonUtil.encodeToString(backStacks) + } + + @TypeConverter + fun toPageBackStackItem(backStacks: String?): MutableList { + return backStacks?.let { + JsonUtil.decodeFromString>(it) + } ?: mutableListOf() + } +} diff --git a/app/src/main/java/org/wikipedia/page/PageFragment.kt b/app/src/main/java/org/wikipedia/page/PageFragment.kt index 2efa21d9389..578bf9fc67d 100644 --- a/app/src/main/java/org/wikipedia/page/PageFragment.kt +++ b/app/src/main/java/org/wikipedia/page/PageFragment.kt @@ -514,8 +514,8 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi } private fun selectedTabPosition(title: PageTitle): Int { - return app.tabList.firstOrNull { it.backStackPositionTitle != null && - title == it.backStackPositionTitle }?.let { app.tabList.indexOf(it) } ?: -1 + return app.tabList.firstOrNull { it.getBackStackPositionTitle() != null && + title == it.getBackStackPositionTitle() }?.let { app.tabList.indexOf(it) } ?: -1 } private fun openInNewTab(title: PageTitle, entry: HistoryEntry, position: Int) { @@ -541,7 +541,7 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi lifecycleScope.launch(CoroutineExceptionHandler { _, t -> L.e(t) }) { ServiceFactory.get(title.wikiSite).getInfoByPageIdsOrTitles(null, title.prefixedText) .query?.firstPage()?.let { page -> - WikipediaApp.instance.tabList.find { it.backStackPositionTitle == title }?.backStackPositionTitle?.apply { + WikipediaApp.instance.tabList.find { it.getBackStackPositionTitle() == title }?.getBackStackPositionTitle()?.apply { thumbUrl = page.thumbUrl() description = page.description } diff --git a/app/src/main/java/org/wikipedia/page/tabs/Tab.kt b/app/src/main/java/org/wikipedia/page/tabs/Tab.kt index 6dbc9200f64..08a4d0717d6 100644 --- a/app/src/main/java/org/wikipedia/page/tabs/Tab.kt +++ b/app/src/main/java/org/wikipedia/page/tabs/Tab.kt @@ -1,21 +1,29 @@ package org.wikipedia.page.tabs +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.TypeConverters import kotlinx.serialization.Serializable +import org.wikipedia.database.PageBackStackItemTypeConverter import org.wikipedia.page.PageBackStackItem import org.wikipedia.page.PageTitle +@Entity @Serializable -class Tab { - val backStack = mutableListOf() - +@TypeConverters(PageBackStackItemTypeConverter::class) +class Tab( + @PrimaryKey(autoGenerate = true) var id: Long = 0, + val backStack: MutableList = mutableListOf() +) { var backStackPosition: Int = -1 get() = if (field < 0) backStack.size - 1 else field - val backStackPositionTitle: PageTitle? - get() = if (backStack.isEmpty()) null else backStack[backStackPosition].title + fun getBackStackPositionTitle(): PageTitle? { + return backStack.getOrNull(backStackPosition)?.title + } fun setBackStackPositionTitle(title: PageTitle) { - backStackPositionTitle?.run { + getBackStackPositionTitle()?.run { backStack[backStackPosition].title = title } } diff --git a/app/src/main/java/org/wikipedia/page/tabs/TabActivity.kt b/app/src/main/java/org/wikipedia/page/tabs/TabActivity.kt index 1d911ea7a79..8a97c1d709b 100644 --- a/app/src/main/java/org/wikipedia/page/tabs/TabActivity.kt +++ b/app/src/main/java/org/wikipedia/page/tabs/TabActivity.kt @@ -129,7 +129,7 @@ class TabActivity : BaseActivity() { } private fun saveTabsToList() { - val titlesList = app.tabList.filter { it.backStackPositionTitle != null }.map { it.backStackPositionTitle!! } + val titlesList = app.tabList.filter { it.getBackStackPositionTitle() != null }.map { it.getBackStackPositionTitle()!! } ExclusiveBottomSheetPresenter.show(supportFragmentManager, AddToReadingListDialog.newInstance(titlesList, InvokeSource.TABS_ACTIVITY)) } @@ -145,7 +145,7 @@ class TabActivity : BaseActivity() { } private fun showUndoSnackbar(index: Int, appTab: Tab, adapterPosition: Int) { - appTab.backStackPositionTitle?.let { + appTab.getBackStackPositionTitle()?.let { FeedbackUtil.makeSnackbar(this, getString(R.string.tab_item_closed, it.displayText)).run { setAction(R.string.reading_list_item_delete_undo) { app.tabList.add(index, appTab) @@ -203,8 +203,8 @@ class TabActivity : BaseActivity() { private open inner class TabViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), View.OnClickListener, SwipeableTabTouchHelperCallback.Callback { open fun bindItem(tab: Tab, position: Int) { - itemView.findViewById(R.id.tabArticleTitle).text = StringUtil.fromHtml(tab.backStackPositionTitle?.displayText.orEmpty()) - itemView.findViewById(R.id.tabArticleDescription).text = StringUtil.fromHtml(tab.backStackPositionTitle?.description.orEmpty()) + itemView.findViewById(R.id.tabArticleTitle).text = StringUtil.fromHtml(tab.getBackStackPositionTitle()?.displayText.orEmpty()) + itemView.findViewById(R.id.tabArticleDescription).text = StringUtil.fromHtml(tab.getBackStackPositionTitle()?.description.orEmpty()) itemView.findViewById(R.id.tabContainer).setOnClickListener(this) itemView.findViewById(R.id.tabCloseButton).setOnClickListener(this) itemView.findViewById(R.id.tabCardView).run { diff --git a/app/src/main/java/org/wikipedia/page/tabs/TabDao.kt b/app/src/main/java/org/wikipedia/page/tabs/TabDao.kt new file mode 100644 index 00000000000..dff07b6e002 --- /dev/null +++ b/app/src/main/java/org/wikipedia/page/tabs/TabDao.kt @@ -0,0 +1,26 @@ +package org.wikipedia.page.tabs + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Dao +interface TabDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertTabs(tabs: List) + + @Query("SELECT * FROM Tab") + suspend fun getTabs(): List + + @Query("DELETE FROM Tab") + suspend fun deleteAll() + + suspend fun hasTabs(): Boolean { + return withContext(Dispatchers.IO) { + getTabs().isNotEmpty() + } + } +} diff --git a/app/src/main/java/org/wikipedia/search/SearchResultsViewModel.kt b/app/src/main/java/org/wikipedia/search/SearchResultsViewModel.kt index a035a1e0e1e..592cf980c63 100644 --- a/app/src/main/java/org/wikipedia/search/SearchResultsViewModel.kt +++ b/app/src/main/java/org/wikipedia/search/SearchResultsViewModel.kt @@ -127,7 +127,7 @@ class SearchResultsViewModel : ViewModel() { private fun getSearchResultsFromTabs(searchTerm: String): SearchResults { WikipediaApp.instance.tabList.forEach { tab -> - tab.backStackPositionTitle?.let { + tab.getBackStackPositionTitle()?.let { if (StringUtil.fromHtml(it.displayText).contains(searchTerm, true)) { return SearchResults(mutableListOf(SearchResult(it, SearchResult.SearchResultType.TAB_LIST))) } diff --git a/app/src/main/java/org/wikipedia/settings/Prefs.kt b/app/src/main/java/org/wikipedia/settings/Prefs.kt index 61d8ec2c088..f4146f516b8 100644 --- a/app/src/main/java/org/wikipedia/settings/Prefs.kt +++ b/app/src/main/java/org/wikipedia/settings/Prefs.kt @@ -89,13 +89,13 @@ object Prefs { get() = PrefsIoUtil.getString(R.string.preference_key_remote_config, "").orEmpty().ifEmpty { "{}" } set(json) = PrefsIoUtil.setString(R.string.preference_key_remote_config, json) + // TODO: remove on 2026-02-01 var tabs get() = JsonUtil.decodeFromString>(PrefsIoUtil.getString(R.string.preference_key_tabs, null)) ?: emptyList() set(tabs) = PrefsIoUtil.setString(R.string.preference_key_tabs, JsonUtil.encodeToString(tabs)) - val hasTabs get() = PrefsIoUtil.contains(R.string.preference_key_tabs) - + // TODO: remove on 2026-08-01 fun clearTabs() { PrefsIoUtil.remove(R.string.preference_key_tabs) }