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 `