diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 4d0492c3..05311992 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -186,6 +186,8 @@ dependencies {
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.hilt.navigation.compose)
+ implementation(libs.androidx.hilt.work)
+ ksp(libs.androidx.hilt.compiler)
// Room
implementation(libs.room.runtime)
@@ -235,6 +237,7 @@ dependencies {
testImplementation(libs.mockito.kotlin)
testImplementation(libs.mockito.inline)
testImplementation(libs.kotlinx.coroutines.test)
+ testImplementation(libs.androidx.work.testing)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
diff --git a/app/schemas/io.aatricks.easyreader.data.local.AppDatabase/7.json b/app/schemas/io.aatricks.easyreader.data.local.AppDatabase/7.json
new file mode 100644
index 00000000..7e10f4b4
--- /dev/null
+++ b/app/schemas/io.aatricks.easyreader.data.local.AppDatabase/7.json
@@ -0,0 +1,279 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 7,
+ "identityHash": "10a0cd3f0bd0509f9541b5bdc69de3f5",
+ "entities": [
+ {
+ "tableName": "library_items",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `url` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `progress` INTEGER NOT NULL, `isCurrentlyReading` INTEGER NOT NULL, `currentChapter` TEXT NOT NULL, `currentChapterUrl` TEXT NOT NULL, `totalChapters` INTEGER NOT NULL, `contentType` TEXT NOT NULL, `dateAdded` INTEGER NOT NULL, `lastRead` INTEGER NOT NULL, `isDownloading` INTEGER NOT NULL, `lastScrollPosition` REAL NOT NULL, `lastReadIndex` INTEGER NOT NULL, `lastReadOffset` INTEGER NOT NULL, `lastReadOffsetFraction` REAL, `hasUpdates` INTEGER NOT NULL, `chapterSummaries` TEXT NOT NULL, `baseTitle` TEXT NOT NULL, `readingMode` TEXT NOT NULL, `baseNovelUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `isDownloaded` INTEGER NOT NULL, `downloadedAt` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "progress",
+ "columnName": "progress",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isCurrentlyReading",
+ "columnName": "isCurrentlyReading",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "currentChapter",
+ "columnName": "currentChapter",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "currentChapterUrl",
+ "columnName": "currentChapterUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "totalChapters",
+ "columnName": "totalChapters",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "contentType",
+ "columnName": "contentType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dateAdded",
+ "columnName": "dateAdded",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastRead",
+ "columnName": "lastRead",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isDownloading",
+ "columnName": "isDownloading",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastScrollPosition",
+ "columnName": "lastScrollPosition",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastReadIndex",
+ "columnName": "lastReadIndex",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastReadOffset",
+ "columnName": "lastReadOffset",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastReadOffsetFraction",
+ "columnName": "lastReadOffsetFraction",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "hasUpdates",
+ "columnName": "hasUpdates",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chapterSummaries",
+ "columnName": "chapterSummaries",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "baseTitle",
+ "columnName": "baseTitle",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "readingMode",
+ "columnName": "readingMode",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "baseNovelUrl",
+ "columnName": "baseNovelUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sourceName",
+ "columnName": "sourceName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isDownloaded",
+ "columnName": "isDownloaded",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "downloadedAt",
+ "columnName": "downloadedAt",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_library_items_url",
+ "unique": true,
+ "columnNames": [
+ "url"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_library_items_url` ON `${TABLE_NAME}` (`url`)"
+ },
+ {
+ "name": "index_library_items_baseTitle",
+ "unique": false,
+ "columnNames": [
+ "baseTitle"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_library_items_baseTitle` ON `${TABLE_NAME}` (`baseTitle`)"
+ },
+ {
+ "name": "index_library_items_isCurrentlyReading",
+ "unique": false,
+ "columnNames": [
+ "isCurrentlyReading"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_library_items_isCurrentlyReading` ON `${TABLE_NAME}` (`isCurrentlyReading`)"
+ },
+ {
+ "name": "index_library_items_lastRead",
+ "unique": false,
+ "columnNames": [
+ "lastRead"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_library_items_lastRead` ON `${TABLE_NAME}` (`lastRead`)"
+ }
+ ]
+ },
+ {
+ "tableName": "chapter_image_state",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chapterUrl` TEXT NOT NULL, `imageUrl` TEXT NOT NULL, `status` TEXT NOT NULL, `attempts` INTEGER NOT NULL, `lastAttemptMs` INTEGER NOT NULL, `httpStatusCode` INTEGER, PRIMARY KEY(`chapterUrl`, `imageUrl`))",
+ "fields": [
+ {
+ "fieldPath": "chapterUrl",
+ "columnName": "chapterUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "imageUrl",
+ "columnName": "imageUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "attempts",
+ "columnName": "attempts",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastAttemptMs",
+ "columnName": "lastAttemptMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "httpStatusCode",
+ "columnName": "httpStatusCode",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "chapterUrl",
+ "imageUrl"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_chapter_image_state_chapterUrl",
+ "unique": false,
+ "columnNames": [
+ "chapterUrl"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_chapter_image_state_chapterUrl` ON `${TABLE_NAME}` (`chapterUrl`)"
+ },
+ {
+ "name": "index_chapter_image_state_status",
+ "unique": false,
+ "columnNames": [
+ "status"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_chapter_image_state_status` ON `${TABLE_NAME}` (`status`)"
+ }
+ ]
+ }
+ ],
+ "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, '10a0cd3f0bd0509f9541b5bdc69de3f5')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/schemas/io.aatricks.easyreader.data.local.AppDatabase/8.json b/app/schemas/io.aatricks.easyreader.data.local.AppDatabase/8.json
new file mode 100644
index 00000000..698e19ed
--- /dev/null
+++ b/app/schemas/io.aatricks.easyreader.data.local.AppDatabase/8.json
@@ -0,0 +1,280 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 8,
+ "identityHash": "a7814b493bd617a88fe851c538ddd6aa",
+ "entities": [
+ {
+ "tableName": "library_items",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `url` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `progress` INTEGER NOT NULL, `isCurrentlyReading` INTEGER NOT NULL, `currentChapter` TEXT NOT NULL, `currentChapterUrl` TEXT NOT NULL, `totalChapters` INTEGER NOT NULL, `contentType` TEXT NOT NULL, `dateAdded` INTEGER NOT NULL, `lastRead` INTEGER NOT NULL, `isDownloading` INTEGER NOT NULL, `lastScrollPosition` REAL NOT NULL, `lastReadIndex` INTEGER NOT NULL, `lastReadElementKey` TEXT NOT NULL, `lastReadOffsetFraction` REAL NOT NULL, `hasUpdates` INTEGER NOT NULL, `chapterSummaries` TEXT NOT NULL, `baseTitle` TEXT NOT NULL, `readingMode` TEXT NOT NULL, `baseNovelUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `isDownloaded` INTEGER NOT NULL, `downloadedAt` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "progress",
+ "columnName": "progress",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isCurrentlyReading",
+ "columnName": "isCurrentlyReading",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "currentChapter",
+ "columnName": "currentChapter",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "currentChapterUrl",
+ "columnName": "currentChapterUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "totalChapters",
+ "columnName": "totalChapters",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "contentType",
+ "columnName": "contentType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dateAdded",
+ "columnName": "dateAdded",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastRead",
+ "columnName": "lastRead",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isDownloading",
+ "columnName": "isDownloading",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastScrollPosition",
+ "columnName": "lastScrollPosition",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastReadIndex",
+ "columnName": "lastReadIndex",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastReadElementKey",
+ "columnName": "lastReadElementKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastReadOffsetFraction",
+ "columnName": "lastReadOffsetFraction",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hasUpdates",
+ "columnName": "hasUpdates",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chapterSummaries",
+ "columnName": "chapterSummaries",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "baseTitle",
+ "columnName": "baseTitle",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "readingMode",
+ "columnName": "readingMode",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "baseNovelUrl",
+ "columnName": "baseNovelUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sourceName",
+ "columnName": "sourceName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isDownloaded",
+ "columnName": "isDownloaded",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "downloadedAt",
+ "columnName": "downloadedAt",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_library_items_url",
+ "unique": true,
+ "columnNames": [
+ "url"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_library_items_url` ON `${TABLE_NAME}` (`url`)"
+ },
+ {
+ "name": "index_library_items_baseTitle",
+ "unique": false,
+ "columnNames": [
+ "baseTitle"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_library_items_baseTitle` ON `${TABLE_NAME}` (`baseTitle`)"
+ },
+ {
+ "name": "index_library_items_isCurrentlyReading",
+ "unique": false,
+ "columnNames": [
+ "isCurrentlyReading"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_library_items_isCurrentlyReading` ON `${TABLE_NAME}` (`isCurrentlyReading`)"
+ },
+ {
+ "name": "index_library_items_lastRead",
+ "unique": false,
+ "columnNames": [
+ "lastRead"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_library_items_lastRead` ON `${TABLE_NAME}` (`lastRead`)"
+ }
+ ]
+ },
+ {
+ "tableName": "chapter_image_state",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chapterUrl` TEXT NOT NULL, `imageUrl` TEXT NOT NULL, `status` TEXT NOT NULL, `attempts` INTEGER NOT NULL, `lastAttemptMs` INTEGER NOT NULL, `httpStatusCode` INTEGER, PRIMARY KEY(`chapterUrl`, `imageUrl`))",
+ "fields": [
+ {
+ "fieldPath": "chapterUrl",
+ "columnName": "chapterUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "imageUrl",
+ "columnName": "imageUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "attempts",
+ "columnName": "attempts",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastAttemptMs",
+ "columnName": "lastAttemptMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "httpStatusCode",
+ "columnName": "httpStatusCode",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "chapterUrl",
+ "imageUrl"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_chapter_image_state_chapterUrl",
+ "unique": false,
+ "columnNames": [
+ "chapterUrl"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_chapter_image_state_chapterUrl` ON `${TABLE_NAME}` (`chapterUrl`)"
+ },
+ {
+ "name": "index_chapter_image_state_status",
+ "unique": false,
+ "columnNames": [
+ "status"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_chapter_image_state_status` ON `${TABLE_NAME}` (`status`)"
+ }
+ ]
+ }
+ ],
+ "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, 'a7814b493bd617a88fe851c538ddd6aa')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/schemas/io.aatricks.easyreader.data.local.AppDatabase/9.json b/app/schemas/io.aatricks.easyreader.data.local.AppDatabase/9.json
new file mode 100644
index 00000000..6b81e97e
--- /dev/null
+++ b/app/schemas/io.aatricks.easyreader.data.local.AppDatabase/9.json
@@ -0,0 +1,322 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 9,
+ "identityHash": "418d090f12fe7a637c75bdd200954d84",
+ "entities": [
+ {
+ "tableName": "library_items",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `url` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `progress` INTEGER NOT NULL, `isCurrentlyReading` INTEGER NOT NULL, `currentChapter` TEXT NOT NULL, `currentChapterUrl` TEXT NOT NULL, `totalChapters` INTEGER NOT NULL, `contentType` TEXT NOT NULL, `dateAdded` INTEGER NOT NULL, `lastRead` INTEGER NOT NULL, `isDownloading` INTEGER NOT NULL, `lastScrollPosition` REAL NOT NULL, `lastReadIndex` INTEGER NOT NULL, `lastReadElementKey` TEXT NOT NULL, `lastReadOffsetFraction` REAL NOT NULL, `hasUpdates` INTEGER NOT NULL, `chapterSummaries` TEXT NOT NULL, `baseTitle` TEXT NOT NULL, `readingMode` TEXT NOT NULL, `baseNovelUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `isDownloaded` INTEGER NOT NULL, `downloadedAt` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "progress",
+ "columnName": "progress",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isCurrentlyReading",
+ "columnName": "isCurrentlyReading",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "currentChapter",
+ "columnName": "currentChapter",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "currentChapterUrl",
+ "columnName": "currentChapterUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "totalChapters",
+ "columnName": "totalChapters",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "contentType",
+ "columnName": "contentType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dateAdded",
+ "columnName": "dateAdded",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastRead",
+ "columnName": "lastRead",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isDownloading",
+ "columnName": "isDownloading",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastScrollPosition",
+ "columnName": "lastScrollPosition",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastReadIndex",
+ "columnName": "lastReadIndex",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastReadElementKey",
+ "columnName": "lastReadElementKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastReadOffsetFraction",
+ "columnName": "lastReadOffsetFraction",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hasUpdates",
+ "columnName": "hasUpdates",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chapterSummaries",
+ "columnName": "chapterSummaries",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "baseTitle",
+ "columnName": "baseTitle",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "readingMode",
+ "columnName": "readingMode",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "baseNovelUrl",
+ "columnName": "baseNovelUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sourceName",
+ "columnName": "sourceName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isDownloaded",
+ "columnName": "isDownloaded",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "downloadedAt",
+ "columnName": "downloadedAt",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_library_items_url",
+ "unique": true,
+ "columnNames": [
+ "url"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_library_items_url` ON `${TABLE_NAME}` (`url`)"
+ },
+ {
+ "name": "index_library_items_baseTitle",
+ "unique": false,
+ "columnNames": [
+ "baseTitle"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_library_items_baseTitle` ON `${TABLE_NAME}` (`baseTitle`)"
+ },
+ {
+ "name": "index_library_items_isCurrentlyReading",
+ "unique": false,
+ "columnNames": [
+ "isCurrentlyReading"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_library_items_isCurrentlyReading` ON `${TABLE_NAME}` (`isCurrentlyReading`)"
+ },
+ {
+ "name": "index_library_items_lastRead",
+ "unique": false,
+ "columnNames": [
+ "lastRead"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_library_items_lastRead` ON `${TABLE_NAME}` (`lastRead`)"
+ }
+ ]
+ },
+ {
+ "tableName": "chapter_image_state",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chapterUrl` TEXT NOT NULL, `imageUrl` TEXT NOT NULL, `status` TEXT NOT NULL, `attempts` INTEGER NOT NULL, `lastAttemptMs` INTEGER NOT NULL, `httpStatusCode` INTEGER, PRIMARY KEY(`chapterUrl`, `imageUrl`))",
+ "fields": [
+ {
+ "fieldPath": "chapterUrl",
+ "columnName": "chapterUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "imageUrl",
+ "columnName": "imageUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "attempts",
+ "columnName": "attempts",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastAttemptMs",
+ "columnName": "lastAttemptMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "httpStatusCode",
+ "columnName": "httpStatusCode",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "chapterUrl",
+ "imageUrl"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_chapter_image_state_chapterUrl",
+ "unique": false,
+ "columnNames": [
+ "chapterUrl"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_chapter_image_state_chapterUrl` ON `${TABLE_NAME}` (`chapterUrl`)"
+ },
+ {
+ "name": "index_chapter_image_state_status",
+ "unique": false,
+ "columnNames": [
+ "status"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_chapter_image_state_status` ON `${TABLE_NAME}` (`status`)"
+ }
+ ]
+ },
+ {
+ "tableName": "image_dimension_cache",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`imageUrl` TEXT NOT NULL, `width` INTEGER NOT NULL, `height` INTEGER NOT NULL, `cachedAtMs` INTEGER NOT NULL, `parserVersion` INTEGER NOT NULL, PRIMARY KEY(`imageUrl`))",
+ "fields": [
+ {
+ "fieldPath": "imageUrl",
+ "columnName": "imageUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "width",
+ "columnName": "width",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "height",
+ "columnName": "height",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "cachedAtMs",
+ "columnName": "cachedAtMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "parserVersion",
+ "columnName": "parserVersion",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "imageUrl"
+ ]
+ }
+ }
+ ],
+ "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, '418d090f12fe7a637c75bdd200954d84')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/benchmark/java/io/aatricks/easyreader/data/repository/ContentRepositoryBenchmarkTest.kt b/app/src/benchmark/java/io/aatricks/easyreader/data/repository/ContentRepositoryBenchmarkTest.kt
index 51d6b2a4..f44ee0cd 100644
--- a/app/src/benchmark/java/io/aatricks/easyreader/data/repository/ContentRepositoryBenchmarkTest.kt
+++ b/app/src/benchmark/java/io/aatricks/easyreader/data/repository/ContentRepositoryBenchmarkTest.kt
@@ -5,6 +5,10 @@ import io.aatricks.easyreader.data.model.ContentElement
import io.aatricks.easyreader.data.repository.content.WebContentLoader
import io.aatricks.easyreader.data.repository.content.ImageCache
import io.aatricks.easyreader.data.repository.content.ImageDownloader
+import io.aatricks.easyreader.data.repository.content.InMemoryPermanentFailureStore
+import io.aatricks.easyreader.data.repository.content.ParsedContentCache
+import io.aatricks.easyreader.data.repository.content.WebOfflineChapterStore
+import io.aatricks.easyreader.testutil.fakeImageDimensionCacheRepository
import kotlinx.coroutines.runBlocking
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
@@ -53,7 +57,23 @@ class ContentRepositoryBenchmarkTest {
.addInterceptor(interceptor)
.build()
- val webLoader = WebContentLoader(mockHtmlParser, okHttpClient, ImageCache(mediaCacheDir), ImageDownloader(okHttpClient), cacheDir)
+ val htmlDownloadsDir = File(cacheDir, "html_downloads").apply { mkdirs() }
+ val mediaDownloadsDir = File(cacheDir, "media_downloads").apply { mkdirs() }
+ val webOfflineDir = File(cacheDir, "web_offline").apply { mkdirs() }
+ val imageCache = ImageCache(mediaCacheDir, mediaDownloadsDir)
+ val imageDownloader = ImageDownloader(okHttpClient)
+ val webLoader = WebContentLoader(
+ mockHtmlParser,
+ okHttpClient,
+ imageCache,
+ imageDownloader,
+ ParsedContentCache(),
+ cacheDir,
+ htmlDownloadsDir,
+ InMemoryPermanentFailureStore(),
+ fakeImageDimensionCacheRepository(),
+ WebOfflineChapterStore(webOfflineDir, mockHtmlParser, imageDownloader, imageCache)
+ )
// Generate images
val imageUrls = (1..imageCount).map { "http://example.com/img_$it.jpg" }
diff --git a/app/src/benchmark/java/io/aatricks/easyreader/data/repository/ContentRepositoryConcurrencyBenchmarkTest.kt b/app/src/benchmark/java/io/aatricks/easyreader/data/repository/ContentRepositoryConcurrencyBenchmarkTest.kt
index bc396e2c..bc008143 100644
--- a/app/src/benchmark/java/io/aatricks/easyreader/data/repository/ContentRepositoryConcurrencyBenchmarkTest.kt
+++ b/app/src/benchmark/java/io/aatricks/easyreader/data/repository/ContentRepositoryConcurrencyBenchmarkTest.kt
@@ -11,6 +11,10 @@ import io.aatricks.easyreader.data.repository.content.PdfDocumentOpener
import io.aatricks.easyreader.data.repository.content.WebContentLoader
import io.aatricks.easyreader.data.repository.content.ImageCache
import io.aatricks.easyreader.data.repository.content.ImageDownloader
+import io.aatricks.easyreader.data.repository.content.InMemoryPermanentFailureStore
+import io.aatricks.easyreader.data.repository.content.ParsedContentCache
+import io.aatricks.easyreader.data.repository.content.WebOfflineChapterStore
+import io.aatricks.easyreader.testutil.fakeImageDimensionCacheRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
@@ -112,7 +116,23 @@ class ContentRepositoryConcurrencyBenchmarkTest {
.addInterceptor(interceptor)
.build()
- val webLoader = WebContentLoader(mockHtmlParser, okHttpClient, ImageCache(mediaCacheDir), ImageDownloader(okHttpClient), cacheDir)
+ val htmlDownloadsDir = File(cacheDir, "html_downloads").apply { mkdirs() }
+ val mediaDownloadsDir = File(cacheDir, "media_downloads").apply { mkdirs() }
+ val webOfflineDir = File(cacheDir, "web_offline").apply { mkdirs() }
+ val imageCache = ImageCache(mediaCacheDir, mediaDownloadsDir)
+ val imageDownloader = ImageDownloader(okHttpClient)
+ val webLoader = WebContentLoader(
+ mockHtmlParser,
+ okHttpClient,
+ imageCache,
+ imageDownloader,
+ ParsedContentCache(),
+ cacheDir,
+ htmlDownloadsDir,
+ InMemoryPermanentFailureStore(),
+ fakeImageDimensionCacheRepository(),
+ WebOfflineChapterStore(webOfflineDir, mockHtmlParser, imageDownloader, imageCache)
+ )
val pdfLoader = PdfContentLoader(mockContext, DefaultPdfDocumentOpener(mockContext))
val epubLoader = EpubContentLoader(mockContext, epubCacheDir)
val localLoader = LocalContentLoader(mockContext, mockHtmlParser, pdfLoader, epubLoader, contentUriTypeResolver)
diff --git a/app/src/benchmark/java/io/aatricks/easyreader/data/repository/content/WebContentLoaderBenchmarkTest.kt b/app/src/benchmark/java/io/aatricks/easyreader/data/repository/content/WebContentLoaderBenchmarkTest.kt
index 5e43848c..90d3ac9c 100644
--- a/app/src/benchmark/java/io/aatricks/easyreader/data/repository/content/WebContentLoaderBenchmarkTest.kt
+++ b/app/src/benchmark/java/io/aatricks/easyreader/data/repository/content/WebContentLoaderBenchmarkTest.kt
@@ -3,6 +3,7 @@ package io.aatricks.easyreader.data.repository.content
import io.aatricks.easyreader.data.model.ContentElement
import io.aatricks.easyreader.data.model.PrefetchMode
import io.aatricks.easyreader.data.repository.HtmlParser
+import io.aatricks.easyreader.testutil.fakeImageDimensionCacheRepository
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
@@ -124,12 +125,26 @@ class WebContentLoaderBenchmarkTest {
val root = Files.createTempDirectory("web-loader-bench").toFile()
val htmlCacheDir = File(root, "html_cache").apply { mkdirs() }
val mediaCacheDir = File(root, "media_cache").apply { mkdirs() }
+ val htmlDownloadsDir = File(root, "html_downloads").apply { mkdirs() }
+ val mediaDownloadsDir = File(root, "media_downloads").apply { mkdirs() }
+ val webOfflineDir = File(root, "web_offline").apply { mkdirs() }
val client = OkHttpClient.Builder()
.addInterceptor(interceptor)
.build()
- val imageCache = ImageCache(mediaCacheDir)
+ val imageCache = ImageCache(mediaCacheDir, mediaDownloadsDir)
val imageDownloader = ImageDownloader(client)
- return WebContentLoader(htmlParser, client, imageCache, imageDownloader, htmlCacheDir)
+ return WebContentLoader(
+ htmlParser,
+ client,
+ imageCache,
+ imageDownloader,
+ ParsedContentCache(),
+ htmlCacheDir,
+ htmlDownloadsDir,
+ InMemoryPermanentFailureStore(),
+ fakeImageDimensionCacheRepository(),
+ WebOfflineChapterStore(webOfflineDir, htmlParser, imageDownloader, imageCache)
+ )
}
private fun buildResponse(
diff --git a/app/src/benchmark/java/io/aatricks/easyreader/ui/viewmodel/SummaryViewModelBenchmarkTest.kt b/app/src/benchmark/java/io/aatricks/easyreader/ui/viewmodel/SummaryViewModelBenchmarkTest.kt
index 13a24d36..3d725685 100644
--- a/app/src/benchmark/java/io/aatricks/easyreader/ui/viewmodel/SummaryViewModelBenchmarkTest.kt
+++ b/app/src/benchmark/java/io/aatricks/easyreader/ui/viewmodel/SummaryViewModelBenchmarkTest.kt
@@ -1,5 +1,7 @@
package io.aatricks.easyreader.ui.viewmodel
+import androidx.test.core.app.ApplicationProvider
+import io.aatricks.easyreader.data.local.PreferencesManager
import io.aatricks.easyreader.data.repository.SummaryService
import io.aatricks.easyreader.data.repository.summary.DisabledSummaryEngine
import kotlinx.coroutines.Dispatchers
@@ -26,6 +28,7 @@ class SummaryViewModelBenchmarkTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var summaryEngine: DisabledSummaryEngine
private lateinit var summaryService: SummaryService
+ private lateinit var preferencesManager: PreferencesManager
private lateinit var viewModel: SummaryViewModel
@Before
@@ -33,7 +36,8 @@ class SummaryViewModelBenchmarkTest {
Dispatchers.setMain(testDispatcher)
summaryEngine = DisabledSummaryEngine()
summaryService = SummaryService(summaryEngine)
- viewModel = SummaryViewModel(summaryService)
+ preferencesManager = PreferencesManager(ApplicationProvider.getApplicationContext())
+ viewModel = SummaryViewModel(summaryService, preferencesManager)
}
@After
@@ -65,13 +69,15 @@ class SummaryViewModelBenchmarkTest {
}
@Test
- fun `initializeSummaryService should fail gracefully with disabled engine`() = runTest {
+ fun `initializeSummaryService is a no-op when the build does not support AI`() = runTest {
viewModel.initializeSummaryService()
-
+
advanceUntilIdle()
-
+
val state = viewModel.uiState.value
+ assertFalse(state.supportsAi)
+ assertFalse(state.isEnabled)
assertFalse(state.isInitializing)
- assertEquals("AI Summarization is disabled in this build.", state.error)
+ assertNull(state.error)
}
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 957f471d..89f8b6a0 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -72,6 +72,19 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/io/aatricks/easyreader/EasyReaderApplication.kt b/app/src/main/java/io/aatricks/easyreader/EasyReaderApplication.kt
index 21e1f78f..9a39b269 100644
--- a/app/src/main/java/io/aatricks/easyreader/EasyReaderApplication.kt
+++ b/app/src/main/java/io/aatricks/easyreader/EasyReaderApplication.kt
@@ -2,6 +2,8 @@ package io.aatricks.easyreader
import android.app.Application
import android.util.Log
+import androidx.hilt.work.HiltWorkerFactory
+import androidx.work.Configuration
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.SingletonImageLoader
@@ -9,10 +11,14 @@ import coil3.memory.MemoryCache
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade
import io.aatricks.easyreader.data.local.PreferencesManager
+import io.aatricks.easyreader.data.model.ContentType
import io.aatricks.easyreader.data.repository.ContentRepository
+import io.aatricks.easyreader.data.repository.ImageDimensionCacheRepository
+import io.aatricks.easyreader.data.repository.LibraryRepository
import io.aatricks.easyreader.data.repository.content.EpubImageFetcher
import io.aatricks.easyreader.data.repository.content.HttpMediaCacheFetcher
import io.aatricks.easyreader.util.CrashRecorder
+import io.aatricks.easyreader.work.ChapterDownloadQueue
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -22,18 +28,62 @@ import okhttp3.OkHttpClient
import javax.inject.Inject
@HiltAndroidApp
-class EasyReaderApplication : Application(), SingletonImageLoader.Factory {
+class EasyReaderApplication : Application(), SingletonImageLoader.Factory, Configuration.Provider {
@Inject lateinit var okHttpClient: OkHttpClient
@Inject lateinit var contentRepository: ContentRepository
+ @Inject lateinit var libraryRepository: LibraryRepository
+ @Inject lateinit var chapterDownloadQueue: ChapterDownloadQueue
@Inject lateinit var preferencesManager: PreferencesManager
+ @Inject lateinit var workerFactory: HiltWorkerFactory
+ @Inject lateinit var imageDimensionCache: ImageDimensionCacheRepository
+
+ // WorkManager pulls this lazily before its first enqueue, which happens after Hilt
+ // injection has populated `workerFactory`. Using on-demand initialization (no manual
+ // `WorkManager.initialize`) means the test variant can override via WorkManagerTestInitHelper.
+ override val workManagerConfiguration: Configuration
+ get() = Configuration.Builder()
+ .setWorkerFactory(workerFactory)
+ .setMinimumLoggingLevel(Log.INFO)
+ .build()
private val warmupScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override fun onCreate() {
super.onCreate()
CrashRecorder.install(this)
- prewarmLastReadChapter()
+ if (!resetLegacyWebOfflinePipelineIfNeeded()) {
+ prewarmLastReadChapter()
+ }
+ pruneImageDimensionCache()
+ }
+
+ private fun resetLegacyWebOfflinePipelineIfNeeded(): Boolean {
+ if (preferencesManager.webOfflinePipelineVersion >= WEB_OFFLINE_PIPELINE_VERSION) return false
+ warmupScope.launch {
+ runCatching {
+ val previouslyDownloadedWeb = libraryRepository.getDownloadedItems()
+ .filter { it.contentType == ContentType.WEB }
+ contentRepository.resetWebOfflinePipelineData()
+ previouslyDownloadedWeb.forEach { item ->
+ libraryRepository.markDownloaded(item.id, false)
+ chapterDownloadQueue.enqueue(item.url, replaceExisting = true)
+ }
+ preferencesManager.webOfflinePipelineVersion = WEB_OFFLINE_PIPELINE_VERSION
+ Log.i(TAG, "reset legacy web offline data and requeued ${previouslyDownloadedWeb.size} chapters")
+ }.onFailure {
+ Log.w(TAG, "web offline reset failed message=${it.message}")
+ }
+ prewarmLastReadChapter()
+ }
+ return true
+ }
+
+ private fun pruneImageDimensionCache() {
+ warmupScope.launch {
+ runCatching { imageDimensionCache.prune() }
+ .onFailure { Log.w(TAG, "image dim cache prune failed message=${it.message}") }
+ }
}
// Kick off chapter parse on a background coroutine so it overlaps Hilt graph build,
@@ -54,6 +104,10 @@ class EasyReaderApplication : Application(), SingletonImageLoader.Factory {
.components {
add(EpubImageFetcher.Factory(contentRepository))
add(HttpMediaCacheFetcher.Factory(contentRepository))
+ // Fallback only: HttpMediaCacheFetcher owns the disk-cached HTTP path and
+ // matches every http(s) URL. OkHttp's fetcher runs only if that one returns
+ // null (offline + cache miss, or a Factory.create bug). Keep it so a
+ // regression in our fetcher doesn't render images unfetchable.
add(OkHttpNetworkFetcherFactory(okHttpClient))
}
.crossfade(false)
@@ -68,5 +122,6 @@ class EasyReaderApplication : Application(), SingletonImageLoader.Factory {
companion object {
private const val TAG = "EasyReaderApplication"
+ private const val WEB_OFFLINE_PIPELINE_VERSION = 2
}
}
diff --git a/app/src/main/java/io/aatricks/easyreader/MainActivity.kt b/app/src/main/java/io/aatricks/easyreader/MainActivity.kt
index 40e23fa2..542f0abf 100644
--- a/app/src/main/java/io/aatricks/easyreader/MainActivity.kt
+++ b/app/src/main/java/io/aatricks/easyreader/MainActivity.kt
@@ -38,6 +38,7 @@ import io.aatricks.easyreader.ui.viewmodel.ReaderViewModel
import io.aatricks.easyreader.util.FileUtils
import io.aatricks.easyreader.util.UrlSecurity
import io.aatricks.easyreader.work.LibraryUpdateWorker
+import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -49,6 +50,7 @@ class MainActivity : ComponentActivity() {
private companion object {
private const val TAG = "MainActivity"
+ private const val DEFERRED_STARTUP_WORK_DELAY_MS = 2_000L
private val URL_REGEX = Regex("https?://[^\\s]+")
}
@@ -81,8 +83,6 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
- LibraryUpdateWorker.schedule(applicationContext)
-
setContent {
val readerUiState by readerViewModel.uiState.collectAsState()
@@ -129,7 +129,7 @@ class MainActivity : ComponentActivity() {
composable {
ReaderScreen(
readerViewModel = readerViewModel,
- libraryViewModel = libraryViewModel,
+ libraryViewModelProvider = { libraryViewModel },
navController = navController,
onOpenFilePicker = { checkPermissionsAndOpenFilePicker() },
modifier = Modifier.fillMaxSize()
@@ -168,9 +168,19 @@ class MainActivity : ComponentActivity() {
}
}
+ scheduleLibraryUpdatesAfterReaderLaunch()
handleIntent(intent)
}
+ private fun scheduleLibraryUpdatesAfterReaderLaunch() {
+ lifecycleScope.launch {
+ // WorkManager setup can touch disk and build more of the app graph. Keep it out
+ // of the first reader frame; the periodic job is best-effort maintenance.
+ delay(DEFERRED_STARTUP_WORK_DELAY_MS)
+ LibraryUpdateWorker.schedule(applicationContext)
+ }
+ }
+
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
diff --git a/app/src/main/java/io/aatricks/easyreader/data/backup/BackupSchema.kt b/app/src/main/java/io/aatricks/easyreader/data/backup/BackupSchema.kt
index 9e13f66a..5c83c144 100644
--- a/app/src/main/java/io/aatricks/easyreader/data/backup/BackupSchema.kt
+++ b/app/src/main/java/io/aatricks/easyreader/data/backup/BackupSchema.kt
@@ -2,7 +2,7 @@ package io.aatricks.easyreader.data.backup
import kotlinx.serialization.Serializable
-const val BACKUP_SCHEMA_VERSION = 1
+const val BACKUP_SCHEMA_VERSION = 2
const val MANIFEST_ENTRY = "manifest.json"
const val EPUB_ENTRY_PREFIX = "epubs/"
@@ -50,8 +50,12 @@ data class LibraryItemBackup(
val isDownloading: Boolean = false,
val lastScrollPosition: Float,
val lastReadIndex: Int,
- val lastReadOffset: Int,
+ val lastReadElementKey: String = "",
val lastReadOffsetFraction: Float? = null,
+ // Legacy schema-v1 raw-pixel anchor. Kept readable so v1 backups still parse; we use it as a
+ // last-resort hint (the post-unification restore path derives index from percent and ignores
+ // pixel offsets, but recording it lets us spot v1 imports in logs if needed).
+ val lastReadOffset: Int = 0,
val hasUpdates: Boolean = false,
val chapterSummaries: Map = emptyMap(),
val baseTitle: String,
diff --git a/app/src/main/java/io/aatricks/easyreader/data/backup/LibraryBackupManager.kt b/app/src/main/java/io/aatricks/easyreader/data/backup/LibraryBackupManager.kt
index 1a9243b6..21692888 100644
--- a/app/src/main/java/io/aatricks/easyreader/data/backup/LibraryBackupManager.kt
+++ b/app/src/main/java/io/aatricks/easyreader/data/backup/LibraryBackupManager.kt
@@ -208,8 +208,8 @@ private fun LibraryItem.toBackup(bundledPath: String?): LibraryItemBackup = Libr
isDownloading = isDownloading,
lastScrollPosition = lastScrollPosition,
lastReadIndex = lastReadIndex,
- lastReadOffset = lastReadOffset,
- lastReadOffsetFraction = lastReadOffsetFraction,
+ lastReadElementKey = lastReadElementKey,
+ lastReadOffsetFraction = lastReadOffsetFraction.takeUnless { it == io.aatricks.easyreader.data.model.FRACTION_UNKNOWN },
hasUpdates = hasUpdates,
chapterSummaries = chapterSummaries,
baseTitle = baseTitle,
@@ -237,8 +237,8 @@ private fun LibraryItemBackup.toEntity(rewrittenUrl: String, fileVerified: Boole
isDownloading = false,
lastScrollPosition = lastScrollPosition,
lastReadIndex = lastReadIndex,
- lastReadOffset = lastReadOffset,
- lastReadOffsetFraction = lastReadOffsetFraction,
+ lastReadElementKey = lastReadElementKey,
+ lastReadOffsetFraction = lastReadOffsetFraction ?: io.aatricks.easyreader.data.model.FRACTION_UNKNOWN,
hasUpdates = hasUpdates,
chapterSummaries = chapterSummaries,
baseTitle = baseTitle.ifBlank { title },
diff --git a/app/src/main/java/io/aatricks/easyreader/data/local/AppDatabase.kt b/app/src/main/java/io/aatricks/easyreader/data/local/AppDatabase.kt
index 1d86b50e..9065f35e 100644
--- a/app/src/main/java/io/aatricks/easyreader/data/local/AppDatabase.kt
+++ b/app/src/main/java/io/aatricks/easyreader/data/local/AppDatabase.kt
@@ -5,12 +5,20 @@ import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
+import io.aatricks.easyreader.data.model.ChapterImageStateEntity
+import io.aatricks.easyreader.data.model.ImageDimensionEntity
import io.aatricks.easyreader.data.model.LibraryItem
-@Database(entities = [LibraryItem::class], version = 6, exportSchema = true)
+@Database(
+ entities = [LibraryItem::class, ChapterImageStateEntity::class, ImageDimensionEntity::class],
+ version = 9,
+ exportSchema = true
+)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun libraryDao(): LibraryDao
+ abstract fun chapterImageStateDao(): ChapterImageStateDao
+ abstract fun imageDimensionDao(): ImageDimensionDao
companion object {
val MIGRATION_1_2 = object : Migration(1, 2) {
@@ -193,5 +201,103 @@ abstract class AppDatabase : RoomDatabase() {
db.execSQL("ALTER TABLE library_items ADD COLUMN downloadedAt INTEGER")
}
}
+
+ val MIGRATION_6_7 = object : Migration(6, 7) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("""
+ CREATE TABLE IF NOT EXISTS chapter_image_state (
+ chapterUrl TEXT NOT NULL,
+ imageUrl TEXT NOT NULL,
+ status TEXT NOT NULL,
+ attempts INTEGER NOT NULL DEFAULT 0,
+ lastAttemptMs INTEGER NOT NULL DEFAULT 0,
+ httpStatusCode INTEGER,
+ PRIMARY KEY(chapterUrl, imageUrl)
+ )
+ """.trimIndent())
+ db.execSQL("CREATE INDEX IF NOT EXISTS index_chapter_image_state_chapterUrl ON chapter_image_state (chapterUrl)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS index_chapter_image_state_status ON chapter_image_state (status)")
+ }
+ }
+
+ /**
+ * Schema unification for reading position:
+ * - Drop unused `lastReadOffset` (raw px — meaningless across reflow / item resize).
+ * - Add `lastReadElementKey` (stable per-element anchor; "" = unset).
+ * - Make `lastReadOffsetFraction` NOT NULL with sentinel -1.0 (= unknown), backfilling NULL rows.
+ */
+ val MIGRATION_7_8 = object : Migration(7, 8) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("""
+ CREATE TABLE library_items_new (
+ id TEXT NOT NULL PRIMARY KEY,
+ title TEXT NOT NULL,
+ url TEXT NOT NULL,
+ timestamp INTEGER NOT NULL,
+ progress INTEGER NOT NULL,
+ isCurrentlyReading INTEGER NOT NULL,
+ currentChapter TEXT NOT NULL,
+ currentChapterUrl TEXT NOT NULL,
+ totalChapters INTEGER NOT NULL,
+ contentType TEXT NOT NULL,
+ dateAdded INTEGER NOT NULL,
+ lastRead INTEGER NOT NULL,
+ isDownloading INTEGER NOT NULL,
+ lastScrollPosition REAL NOT NULL,
+ lastReadIndex INTEGER NOT NULL,
+ lastReadElementKey TEXT NOT NULL DEFAULT '',
+ lastReadOffsetFraction REAL NOT NULL DEFAULT -1,
+ hasUpdates INTEGER NOT NULL,
+ chapterSummaries TEXT NOT NULL,
+ baseTitle TEXT NOT NULL,
+ readingMode TEXT NOT NULL,
+ baseNovelUrl TEXT NOT NULL,
+ sourceName TEXT NOT NULL,
+ isDownloaded INTEGER NOT NULL DEFAULT 0,
+ downloadedAt INTEGER
+ )
+ """.trimIndent())
+
+ db.execSQL("""
+ INSERT INTO library_items_new (
+ id, title, url, timestamp, progress, isCurrentlyReading,
+ currentChapter, currentChapterUrl, totalChapters, contentType,
+ dateAdded, lastRead, isDownloading, lastScrollPosition,
+ lastReadIndex, lastReadElementKey, lastReadOffsetFraction, hasUpdates,
+ chapterSummaries, baseTitle, readingMode, baseNovelUrl, sourceName,
+ isDownloaded, downloadedAt
+ ) SELECT
+ id, title, url, timestamp, progress, isCurrentlyReading,
+ currentChapter, currentChapterUrl, totalChapters, contentType,
+ dateAdded, lastRead, isDownloading, lastScrollPosition,
+ lastReadIndex, '', COALESCE(lastReadOffsetFraction, -1.0), hasUpdates,
+ chapterSummaries, baseTitle, readingMode, baseNovelUrl, sourceName,
+ isDownloaded, downloadedAt
+ FROM library_items
+ """.trimIndent())
+
+ db.execSQL("DROP TABLE library_items")
+ db.execSQL("ALTER TABLE library_items_new RENAME TO library_items")
+
+ db.execSQL("CREATE UNIQUE INDEX index_library_items_url ON library_items (url)")
+ db.execSQL("CREATE INDEX index_library_items_baseTitle ON library_items (baseTitle)")
+ db.execSQL("CREATE INDEX index_library_items_isCurrentlyReading ON library_items (isCurrentlyReading)")
+ db.execSQL("CREATE INDEX index_library_items_lastRead ON library_items (lastRead)")
+ }
+ }
+
+ val MIGRATION_8_9 = object : Migration(8, 9) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("""
+ CREATE TABLE IF NOT EXISTS image_dimension_cache (
+ imageUrl TEXT NOT NULL PRIMARY KEY,
+ width INTEGER NOT NULL,
+ height INTEGER NOT NULL,
+ cachedAtMs INTEGER NOT NULL,
+ parserVersion INTEGER NOT NULL
+ )
+ """.trimIndent())
+ }
+ }
}
}
diff --git a/app/src/main/java/io/aatricks/easyreader/data/local/ChapterImageStateDao.kt b/app/src/main/java/io/aatricks/easyreader/data/local/ChapterImageStateDao.kt
new file mode 100644
index 00000000..f1181805
--- /dev/null
+++ b/app/src/main/java/io/aatricks/easyreader/data/local/ChapterImageStateDao.kt
@@ -0,0 +1,53 @@
+package io.aatricks.easyreader.data.local
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import io.aatricks.easyreader.data.model.ChapterImageStateEntity
+
+@Dao
+interface ChapterImageStateDao {
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun upsert(entity: ChapterImageStateEntity)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun upsertAll(entities: List)
+
+ @Query("""
+ SELECT imageUrl FROM chapter_image_state
+ WHERE chapterUrl = :chapterUrl
+ AND status = :status
+ AND lastAttemptMs > :freshAfterMs
+ """)
+ suspend fun getActiveImagesByStatus(
+ chapterUrl: String,
+ status: String,
+ freshAfterMs: Long
+ ): List
+
+ @Query("""
+ SELECT * FROM chapter_image_state
+ WHERE chapterUrl = :chapterUrl
+ AND status = :status
+ """)
+ suspend fun getAllByStatus(
+ chapterUrl: String,
+ status: String
+ ): List
+
+ @Query("DELETE FROM chapter_image_state WHERE chapterUrl = :chapterUrl")
+ suspend fun deleteAllForChapter(chapterUrl: String)
+
+ @Query("""
+ DELETE FROM chapter_image_state
+ WHERE chapterUrl = :chapterUrl AND status = :status
+ """)
+ suspend fun deleteForChapterByStatus(chapterUrl: String, status: String)
+
+ @Query("""
+ DELETE FROM chapter_image_state
+ WHERE status = :status AND lastAttemptMs < :olderThanMs
+ """)
+ suspend fun pruneExpired(status: String, olderThanMs: Long)
+}
diff --git a/app/src/main/java/io/aatricks/easyreader/data/local/ImageDimensionDao.kt b/app/src/main/java/io/aatricks/easyreader/data/local/ImageDimensionDao.kt
new file mode 100644
index 00000000..4a0692b2
--- /dev/null
+++ b/app/src/main/java/io/aatricks/easyreader/data/local/ImageDimensionDao.kt
@@ -0,0 +1,28 @@
+package io.aatricks.easyreader.data.local
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import io.aatricks.easyreader.data.model.ImageDimensionEntity
+
+@Dao
+interface ImageDimensionDao {
+ @Query("""
+ SELECT * FROM image_dimension_cache
+ WHERE imageUrl IN (:urls) AND parserVersion = :parserVersion
+ """)
+ suspend fun getMany(urls: List, parserVersion: Int): List
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun upsert(entity: ImageDimensionEntity)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun upsertAll(entities: List)
+
+ @Query("""
+ DELETE FROM image_dimension_cache
+ WHERE cachedAtMs < :cutoffMs OR parserVersion < :currentParserVersion
+ """)
+ suspend fun prune(cutoffMs: Long, currentParserVersion: Int)
+}
diff --git a/app/src/main/java/io/aatricks/easyreader/data/local/LibraryDao.kt b/app/src/main/java/io/aatricks/easyreader/data/local/LibraryDao.kt
index ed9780db..601bde86 100644
--- a/app/src/main/java/io/aatricks/easyreader/data/local/LibraryDao.kt
+++ b/app/src/main/java/io/aatricks/easyreader/data/local/LibraryDao.kt
@@ -58,10 +58,10 @@ interface LibraryDao {
@Query("DELETE FROM library_items")
suspend fun deleteAllItems()
- @Query("UPDATE library_items SET progress = 0, lastScrollPosition = 0, lastReadIndex = 0, lastReadOffset = 0, lastReadOffsetFraction = NULL, lastRead = :timestamp WHERE id = :id")
+ @Query("UPDATE library_items SET progress = 0, lastScrollPosition = 0, lastReadIndex = 0, lastReadElementKey = '', lastReadOffsetFraction = -1, lastRead = :timestamp WHERE id = :id")
suspend fun resetProgress(id: String, timestamp: Long = System.currentTimeMillis())
- @Query("UPDATE library_items SET progress = 0, lastScrollPosition = 0, lastReadIndex = 0, lastReadOffset = 0, lastReadOffsetFraction = NULL WHERE baseTitle = :baseTitle")
+ @Query("UPDATE library_items SET progress = 0, lastScrollPosition = 0, lastReadIndex = 0, lastReadElementKey = '', lastReadOffsetFraction = -1 WHERE baseTitle = :baseTitle")
suspend fun resetProgressByBaseTitle(baseTitle: String)
@Transaction
diff --git a/app/src/main/java/io/aatricks/easyreader/data/local/PreferencesManager.kt b/app/src/main/java/io/aatricks/easyreader/data/local/PreferencesManager.kt
index 08de5847..44f2632a 100644
--- a/app/src/main/java/io/aatricks/easyreader/data/local/PreferencesManager.kt
+++ b/app/src/main/java/io/aatricks/easyreader/data/local/PreferencesManager.kt
@@ -144,7 +144,17 @@ class PreferencesManager @Inject constructor(
get() = prefs.getString(KEY_ACCENT_THEME, io.aatricks.easyreader.ui.theme.AccentTheme.MOSS.name)
?: io.aatricks.easyreader.ui.theme.AccentTheme.MOSS.name
set(value) = prefs.edit().putString(KEY_ACCENT_THEME, value).apply()
-
+
+ // Opt-in for AI summary model. False by default so the model is never
+ // downloaded unless the user explicitly enables the feature.
+ var aiSummaryEnabled: Boolean
+ get() = prefs.getBoolean(KEY_AI_SUMMARY_ENABLED, false)
+ set(value) = prefs.edit().putBoolean(KEY_AI_SUMMARY_ENABLED, value).apply()
+
+ var webOfflinePipelineVersion: Int
+ get() = prefs.getInt(KEY_WEB_OFFLINE_PIPELINE_VERSION, 0)
+ set(value) = prefs.edit().putInt(KEY_WEB_OFFLINE_PIPELINE_VERSION, value).apply()
+
// Clear all preferences
fun clearAll() {
prefs.edit().clear().apply()
@@ -202,5 +212,8 @@ class PreferencesManager @Inject constructor(
private const val KEY_PARAGRAPH_SPACING = "reader_paragraph_spacing"
private const val KEY_READER_THEME = "reader_theme"
private const val KEY_ACCENT_THEME = "accent_theme"
+
+ private const val KEY_AI_SUMMARY_ENABLED = "ai_summary_enabled"
+ private const val KEY_WEB_OFFLINE_PIPELINE_VERSION = "web_offline_pipeline_version"
}
}
diff --git a/app/src/main/java/io/aatricks/easyreader/data/model/ChapterImageStateEntity.kt b/app/src/main/java/io/aatricks/easyreader/data/model/ChapterImageStateEntity.kt
new file mode 100644
index 00000000..f4cb4b1a
--- /dev/null
+++ b/app/src/main/java/io/aatricks/easyreader/data/model/ChapterImageStateEntity.kt
@@ -0,0 +1,38 @@
+package io.aatricks.easyreader.data.model
+
+import androidx.room.Entity
+import androidx.room.Index
+
+/**
+ * Per-image bookkeeping for chapter downloads. Replaces the on-disk `.failed` sidecar so
+ * permanent-failure state survives cache eviction and is queryable as a set rather than a
+ * per-chapter file read. Status values:
+ *
+ * - `PERMANENT_FAILURE` — fetcher classified the URL as a 4xx-style dead URL (see HttpRetry).
+ * Counts toward `isComplete` (loop has nothing more to attempt) but the image is not on
+ * disk; surfaced as `hasPermanentFailures` so the UI/DB-flag won't claim "Downloaded".
+ * Entries have a TTL so a transient CDN 4xx auto-recovers after a day.
+ *
+ * The on-disk image file is still the source of truth for "downloaded"; this table only
+ * tracks what we've given up retrying.
+ */
+@Entity(
+ tableName = "chapter_image_state",
+ primaryKeys = ["chapterUrl", "imageUrl"],
+ indices = [
+ Index(value = ["chapterUrl"]),
+ Index(value = ["status"])
+ ]
+)
+data class ChapterImageStateEntity(
+ val chapterUrl: String,
+ val imageUrl: String,
+ val status: String,
+ val attempts: Int = 0,
+ val lastAttemptMs: Long = 0L,
+ val httpStatusCode: Int? = null
+) {
+ companion object {
+ const val STATUS_PERMANENT_FAILURE = "PERMANENT_FAILURE"
+ }
+}
diff --git a/app/src/main/java/io/aatricks/easyreader/data/model/ImageDimensionEntity.kt b/app/src/main/java/io/aatricks/easyreader/data/model/ImageDimensionEntity.kt
new file mode 100644
index 00000000..158338f6
--- /dev/null
+++ b/app/src/main/java/io/aatricks/easyreader/data/model/ImageDimensionEntity.kt
@@ -0,0 +1,13 @@
+package io.aatricks.easyreader.data.model
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "image_dimension_cache")
+data class ImageDimensionEntity(
+ @PrimaryKey val imageUrl: String,
+ val width: Int,
+ val height: Int,
+ val cachedAtMs: Long,
+ val parserVersion: Int
+)
diff --git a/app/src/main/java/io/aatricks/easyreader/data/model/LibraryItem.kt b/app/src/main/java/io/aatricks/easyreader/data/model/LibraryItem.kt
index 51bc08c7..843adf46 100644
--- a/app/src/main/java/io/aatricks/easyreader/data/model/LibraryItem.kt
+++ b/app/src/main/java/io/aatricks/easyreader/data/model/LibraryItem.kt
@@ -6,9 +6,11 @@ import androidx.room.PrimaryKey
import kotlinx.serialization.Serializable
/**
- * Data class representing a library item in the Novel Scraper app.
- * Immutable by default with validation in init block.
+ * Sentinel used in `lastReadOffsetFraction` to mean "intra-item position not yet measured".
+ * Keeps the column NOT NULL while preserving the "unknown" branch in restore logic.
*/
+const val FRACTION_UNKNOWN: Float = -1f
+
@Serializable
@Entity(
tableName = "library_items",
@@ -36,8 +38,8 @@ data class LibraryItem(
val isDownloading: Boolean = false,
val lastScrollPosition: Float = 0f,
val lastReadIndex: Int = 0,
- val lastReadOffset: Int = 0,
- val lastReadOffsetFraction: Float? = null,
+ val lastReadElementKey: String = "",
+ val lastReadOffsetFraction: Float = FRACTION_UNKNOWN,
val hasUpdates: Boolean = false,
val chapterSummaries: Map = emptyMap(),
val baseTitle: String = "",
@@ -53,34 +55,15 @@ data class LibraryItem(
require(progress in 0..100) { "Progress must be between 0 and 100, got: $progress" }
require(timestamp > 0) { "Timestamp must be positive" }
}
-
- /**
- * Creates a copy of this LibraryItem with updated progress.
- * Ensures progress stays within valid range (0-100).
- */
+
fun withProgress(newProgress: Int): LibraryItem {
val clampedProgress = newProgress.coerceIn(0, 100)
return copy(progress = clampedProgress)
}
-
- /**
- * Creates a copy marking this item as currently reading.
- * Typically used when opening a chapter.
- */
+
fun markAsReading(): LibraryItem = copy(isCurrentlyReading = true)
-
- /**
- * Creates a copy marking this item as not currently reading.
- */
+
fun markAsNotReading(): LibraryItem = copy(isCurrentlyReading = false)
-
- /**
- * Checks if the item has been started (progress > 0).
- */
+
fun isStarted(): Boolean = progress > 0
-
- /**
- * Checks if the item is completed (progress == 100).
- */
- fun isCompleted(): Boolean = progress == 100
}
diff --git a/app/src/main/java/io/aatricks/easyreader/data/model/PrefetchResult.kt b/app/src/main/java/io/aatricks/easyreader/data/model/PrefetchResult.kt
index d9487606..0b300b3b 100644
--- a/app/src/main/java/io/aatricks/easyreader/data/model/PrefetchResult.kt
+++ b/app/src/main/java/io/aatricks/easyreader/data/model/PrefetchResult.kt
@@ -13,5 +13,14 @@ data class PrefetchResult(
val isComplete: Boolean,
val isInProgress: Boolean = false,
val isRetryable: Boolean = true,
- val isPersistentDownload: Boolean = false
+ val isPersistentDownload: Boolean = false,
+ // True when some images were accounted as permanently failed (in the .failed sidecar)
+ // rather than actually on disk. isComplete can still be true (no further work to attempt)
+ // but the chapter is not fully present — opening it will show "Image unavailable" for the
+ // missing images. The DB isDownloaded flag and the "Downloaded" UI label should only
+ // appear when isComplete && !hasPermanentFailures.
+ val hasPermanentFailures: Boolean = false
)
+
+fun PrefetchResult.isStrictOfflineReady(): Boolean =
+ isPersistentDownload && isComplete && !hasPermanentFailures
diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/ContentRepository.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/ContentRepository.kt
index ff322c6e..a90d54d0 100644
--- a/app/src/main/java/io/aatricks/easyreader/data/repository/ContentRepository.kt
+++ b/app/src/main/java/io/aatricks/easyreader/data/repository/ContentRepository.kt
@@ -16,6 +16,7 @@ import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import java.io.File
+import java.util.concurrent.atomic.AtomicLong
import javax.inject.Singleton
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
@@ -39,6 +40,7 @@ class ContentRepository @Inject constructor(
private const val WEB_CHAPTER_LOAD_TIMEOUT_MS = 25_000L
private const val MAX_MEDIA_CACHE_BYTES = 512L * 1024L * 1024L
private const val MAX_HTML_CACHE_BYTES = 64L * 1024L * 1024L
+ private const val CACHE_TRIM_INTERVAL_MS = 30_000L
private const val INSPECT_MEMO_TTL_MS = 3_000L
private val CHAPTER_URL_PATTERNS = listOf(
Regex("(chapter[-_/])(\\d+)", RegexOption.IGNORE_CASE),
@@ -48,6 +50,7 @@ class ContentRepository @Inject constructor(
private data class InspectMemo(val result: PrefetchResult, val storedAt: Long)
private val inspectMemo = java.util.concurrent.ConcurrentHashMap()
+ private val lastCacheTrimAtMs = AtomicLong(0L)
private val userDownloadsInFlight = java.util.concurrent.ConcurrentHashMap.newKeySet()
@@ -101,7 +104,7 @@ class ContentRepository @Inject constructor(
ContentKind.UNKNOWN -> ContentResult.Error("Unsupported file type")
}
if (resolveContentKind(url) == ContentKind.WEB) {
- trimCachesInternal()
+ trimCachesInternal(force = true)
}
result
} catch (e: TimeoutCancellationException) {
@@ -123,20 +126,13 @@ class ContentRepository @Inject constructor(
suspend fun downloadAndCacheImage(
imageUrl: String,
- pageUrl: String,
- tier: StorageTier = StorageTier.CACHE
+ pageUrl: String
): File? = withContext(Dispatchers.IO) {
- webLoader.downloadAndCacheImage(imageUrl, pageUrl, tier).also {
+ webLoader.downloadAndCacheImage(imageUrl, pageUrl).also {
trimCachesInternal()
}
}
- fun isDownloaded(url: String): Boolean = webLoader.isDownloaded(url)
-
- fun isImageDownloaded(imageUrl: String): Boolean = webLoader.isImageDownloaded(imageUrl)
-
- fun promoteImageToDownloads(imageUrl: String): File? = webLoader.promoteImageToDownloads(imageUrl)
-
suspend fun warmImage(imageUrl: String, pageUrl: String): Boolean = withContext(Dispatchers.IO) {
(webLoader.warmImage(imageUrl, pageUrl) != null).also {
trimCachesInternal()
@@ -145,6 +141,15 @@ class ContentRepository @Inject constructor(
fun getCachedMediaFile(url: String): File = webLoader.getCachedMediaFile(url)
+ fun findUsableCachedMediaFile(url: String): File? = webLoader.findUsableCachedMediaFile(url)
+
+ fun getLikelyMediaState(url: String): String = webLoader.getLikelyMediaState(url)
+
+ suspend fun invalidateCachedMediaFile(imageUrl: String, pageUrl: String? = null): Unit = withContext(Dispatchers.IO) {
+ webLoader.invalidateCachedMediaFile(imageUrl)
+ pageUrl?.takeIf { it.isNotBlank() }?.let(::invalidateInspect)
+ }
+
fun getReferer(url: String): String = webLoader.getReferer(url)
fun isCached(url: String): Boolean = webLoader.isCached(url)
@@ -187,6 +192,10 @@ class ContentRepository @Inject constructor(
mode: PrefetchMode,
onProgress: (suspend (PrefetchResult) -> Unit)?
): PrefetchResult = withContext(Dispatchers.IO) {
+ if (mode == PrefetchMode.USER_REQUESTED) {
+ return@withContext downloadChapter(url, onProgress)
+ }
+
invalidateInspect(url)
val result = runCatching {
when (resolveContentKind(url)) {
@@ -229,7 +238,65 @@ class ContentRepository @Inject constructor(
}.getOrElse {
PrefetchResult(url, htmlCached = false, totalImages = 0, cachedImages = 0, isComplete = false, isRetryable = true)
}
- trimCachesInternal()
+ trimCachesInternal(force = true)
+ result
+ }
+
+ suspend fun downloadChapter(
+ url: String,
+ onProgress: (suspend (PrefetchResult) -> Unit)? = null
+ ): PrefetchResult = withContext(Dispatchers.IO) {
+ invalidateInspect(url)
+ val result = runCatching {
+ when (resolveContentKind(url)) {
+ ContentKind.WEB -> webLoader.downloadChapter(url, onProgress)
+ ContentKind.EPUB -> {
+ if (epubLoader.prefetchEpub(url, StorageTier.DOWNLOADS)) {
+ PrefetchResult(
+ url,
+ htmlCached = true,
+ totalImages = 0,
+ cachedImages = 0,
+ isComplete = true,
+ isRetryable = false,
+ isPersistentDownload = true
+ )
+ } else {
+ PrefetchResult(
+ url,
+ htmlCached = false,
+ totalImages = 0,
+ cachedImages = 0,
+ isComplete = false,
+ isRetryable = true,
+ isPersistentDownload = true
+ )
+ }
+ }
+ ContentKind.PDF, ContentKind.HTML, ContentKind.LOCAL ->
+ localContentResult(url, isPersistentDownload = true)
+ ContentKind.UNKNOWN -> PrefetchResult(
+ url,
+ htmlCached = false,
+ totalImages = 0,
+ cachedImages = 0,
+ isComplete = false,
+ isRetryable = false,
+ isPersistentDownload = true
+ )
+ }
+ }.getOrElse {
+ PrefetchResult(
+ url,
+ htmlCached = false,
+ totalImages = 0,
+ cachedImages = 0,
+ isComplete = false,
+ isRetryable = true,
+ isPersistentDownload = true
+ )
+ }
+ trimCachesInternal(force = true)
result
}
@@ -418,6 +485,14 @@ class ContentRepository @Inject constructor(
epubLoader.clearAllDownloads()
}
+ suspend fun resetWebOfflinePipelineData(): Unit = withContext(Dispatchers.IO) {
+ inspectMemo.clear()
+ webLoader.clearAllCache()
+ webLoader.clearAllDownloads()
+ clearHttpCache()
+ clearImageCache()
+ }
+
suspend fun getCacheSize(): Long = withContext(Dispatchers.IO) {
webLoader.getCacheSize() +
epubLoader.getCacheSize() +
@@ -430,7 +505,7 @@ class ContentRepository @Inject constructor(
}
suspend fun trimCaches(): Unit = withContext(Dispatchers.IO) {
- trimCachesInternal()
+ trimCachesInternal(force = true)
}
private fun localContentResult(url: String, isPersistentDownload: Boolean = false): PrefetchResult {
@@ -488,7 +563,15 @@ class ContentRepository @Inject constructor(
.getOrElse { FileSizeUtils.calculateDirectorySize(httpCacheDir) }
}
- private fun trimCachesInternal() {
+ private fun trimCachesInternal(force: Boolean = false) {
+ val now = System.currentTimeMillis()
+ if (!force) {
+ val previous = lastCacheTrimAtMs.get()
+ if (now - previous < CACHE_TRIM_INTERVAL_MS) return
+ if (!lastCacheTrimAtMs.compareAndSet(previous, now)) return
+ } else {
+ lastCacheTrimAtMs.set(now)
+ }
webLoader.trimCaches(
maxHtmlBytes = MAX_HTML_CACHE_BYTES,
maxMediaBytes = MAX_MEDIA_CACHE_BYTES
diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/DownloadStatusReconciler.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/DownloadStatusReconciler.kt
new file mode 100644
index 00000000..7316dec9
--- /dev/null
+++ b/app/src/main/java/io/aatricks/easyreader/data/repository/DownloadStatusReconciler.kt
@@ -0,0 +1,96 @@
+package io.aatricks.easyreader.data.repository
+
+import android.util.Log
+import io.aatricks.easyreader.data.model.LibraryItem
+import io.aatricks.easyreader.data.model.PrefetchResult
+import io.aatricks.easyreader.data.model.isStrictOfflineReady
+import io.aatricks.easyreader.util.UrlSanitizer
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Canonical writer for the [LibraryItem.isDownloaded] flag. Every producer of a
+ * download/inspect result (VM in-process prefetch, ChapterDownloadWorker, startup
+ * verifier, refresh) routes through here so the badge, the DB flag, and the
+ * on-disk image state cannot disagree.
+ *
+ * Promotion: flag → true when [isFullyDownloaded].
+ *
+ * Demotion: flag → false when the result is terminal (not in progress) AND came
+ * from a downloads-tier inspect (`wasUserInspect = true`) AND the chapter is not
+ * fully present on disk. This covers three real failure modes:
+ * - hasPermanentFailures=true (4xx images recorded in the failure store)
+ * - isComplete=false (transient failures that never recovered after retries)
+ * - isPersistentDownload=false (downloads-tier HTML was lost / never written)
+ *
+ * SPECULATIVE / all-tier inspects pass `wasUserInspect = false` so cache-tier
+ * lookups never knock the flag down.
+ */
+@Singleton
+class DownloadStatusReconciler @Inject constructor(
+ private val libraryRepository: LibraryRepository
+) {
+ fun isFullyDownloaded(result: PrefetchResult): Boolean =
+ result.isStrictOfflineReady()
+
+ suspend fun reconcile(
+ item: LibraryItem,
+ result: PrefetchResult,
+ wasUserInspect: Boolean
+ ) {
+ if (isFullyDownloaded(result)) {
+ val action = if (!item.isDownloaded) "promote" else "noop-already-true"
+ logAction(item, result, wasUserInspect, action)
+ if (!item.isDownloaded) {
+ libraryRepository.markDownloaded(item.id, true)
+ }
+ return
+ }
+ if (!wasUserInspect) {
+ logAction(item, result, wasUserInspect, "noop-not-user-inspect")
+ return
+ }
+ if (result.isInProgress) {
+ logAction(item, result, wasUserInspect, "noop-in-progress")
+ return
+ }
+ val action = if (item.isDownloaded) "demote" else "noop-already-false"
+ logAction(item, result, wasUserInspect, action)
+ if (item.isDownloaded) {
+ libraryRepository.markDownloaded(item.id, false)
+ }
+ }
+
+ // TODO(verification): remove this logging after the bulk-download regression is
+ // confirmed fixed on a real device. The fields here are the inputs to every
+ // download-status decision so a logcat trace tells us exactly why a chapter
+ // ended up in the wrong state if the bug recurs.
+ private fun logAction(
+ item: LibraryItem,
+ result: PrefetchResult,
+ wasUserInspect: Boolean,
+ action: String
+ ) {
+ Log.w(
+ TAG,
+ "url=${UrlSanitizer.sanitize(item.url)} action=$action " +
+ "item.isDownloaded=${item.isDownloaded} " +
+ "isComplete=${result.isComplete} hasPerm=${result.hasPermanentFailures} " +
+ "inProgress=${result.isInProgress} persistent=${result.isPersistentDownload} " +
+ "userInspect=$wasUserInspect cached=${result.cachedImages}/${result.totalImages}"
+ )
+ }
+
+ private companion object {
+ private const val TAG = "DownloadReconciler"
+ }
+
+ suspend fun reconcile(
+ url: String,
+ result: PrefetchResult,
+ wasUserInspect: Boolean
+ ) {
+ val item = libraryRepository.getItemByUrl(url) ?: return
+ reconcile(item, result, wasUserInspect)
+ }
+}
diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/HtmlParser.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/HtmlParser.kt
index c753f437..75864dae 100644
--- a/app/src/main/java/io/aatricks/easyreader/data/repository/HtmlParser.kt
+++ b/app/src/main/java/io/aatricks/easyreader/data/repository/HtmlParser.kt
@@ -50,13 +50,31 @@ class HtmlParser @Inject constructor() {
fun parse(document: Document, url: String): List {
cleanDocument(document)
- val images = parseImages(document, url)
+ var images = parseImages(document, url)
val paragraphs = parseParagraphs(document)
val filteredParagraphs = filterParagraphs(paragraphs, document.title())
- // If it looks like a manga (many images or few paragraphs), return images
- if (images.size > 5 || (images.isNotEmpty() && filteredParagraphs.size < 10)) {
+ // Some manga sites (Mangabat, Asura) embed the chapter image list in inline JS
+ // (`chapterImages = [...]`, Astro page-island JSON, etc.) and only hardcode the
+ // cover image as a static
. Without this fallback the parser sees 1 image,
+ // the downloader marks the chapter "complete" after fetching the cover, and the
+ // reader shows a single page offline. Merge JS-extracted URLs into the image list
+ // for chapter URLs where the static parse looks suspiciously thin.
+ if (isChapterPage(url) && images.size <= 2) {
+ val jsImages = extractInlineScriptImageUrls(document, url, alreadyKnown = images.map { it.url }.toSet())
+ if (jsImages.isNotEmpty()) {
+ images = (images + jsImages.map { ContentElement.Image(url = it, width = 0, height = 0) })
+ .distinctBy { it.url }
+ }
+ }
+
+ // Chapter pages from manga sites legitimately have many paragraphs of footer text
+ // (comments, ads, "if you want to read free manga..."). If any chapter image was
+ // extracted from a manga reader container, trust that this is image content and
+ // ignore the boilerplate paragraphs — otherwise the user sees the footer text and
+ // none of the actual manga pages.
+ if (images.isNotEmpty() && (images.size > 5 || isChapterPage(url) || filteredParagraphs.size < 10)) {
return images
}
@@ -67,6 +85,80 @@ class HtmlParser @Inject constructor() {
return mergeAndFormatParagraphs(filteredParagraphs)
}
+ /**
+ * Mangabat / Asura / other JS-rendered chapter pages embed the real page-image list
+ * inside `