From fb9495116a925b594fbf567cbdd734b6119b8ddb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E6=B8=A0=E6=88=90?= <1102339344@qq.com> Date: Tue, 11 Aug 2026 14:20:59 +0800 Subject: [PATCH] feat: add adaptive reading time estimation --- .../java/io/legado/app/data/entities/Book.kt | 8 +- .../java/io/legado/app/help/book/BookHelp.kt | 13 +- .../app/help/book/ReadingTimeIndexManager.kt | 391 +++++++++++++++ .../legado/app/help/config/ReadTipConfig.kt | 21 +- .../main/java/io/legado/app/model/ReadBook.kt | 423 +++++++++++++++- .../model/read/ReadingTimeDisplayFormatter.kt | 70 +++ .../app/model/read/ReadingTimeEstimator.kt | 462 ++++++++++++++++++ .../app/model/read/ReadingTimeIndexCodec.kt | 129 +++++ .../model/read/ReadingTimeIndexReconciler.kt | 69 +++ .../app/ui/book/read/ReadBookActivity.kt | 14 +- .../ui/book/read/config/TipConfigDialog.kt | 26 +- .../legado/app/ui/book/read/page/PageView.kt | 32 +- .../read/page/provider/TextPageFactory.kt | 14 +- app/src/main/res/layout/dialog_tip_config.xml | 10 +- app/src/main/res/values-es-rES/arrays.xml | 3 + app/src/main/res/values-es-rES/strings.xml | 10 + app/src/main/res/values-ja-rJP/strings.xml | 10 + app/src/main/res/values-pt-rBR/arrays.xml | 3 + app/src/main/res/values-pt-rBR/strings.xml | 10 + app/src/main/res/values-vi/arrays.xml | 3 + app/src/main/res/values-vi/strings.xml | 10 + app/src/main/res/values-zh-rHK/arrays.xml | 3 + app/src/main/res/values-zh-rHK/strings.xml | 10 + app/src/main/res/values-zh-rTW/arrays.xml | 3 + app/src/main/res/values-zh-rTW/strings.xml | 10 + app/src/main/res/values-zh/arrays.xml | 5 +- app/src/main/res/values-zh/strings.xml | 10 + app/src/main/res/values/arrays.xml | 3 + app/src/main/res/values/strings.xml | 10 + .../help/config/ReadTipConfigResourceTest.kt | 46 ++ .../model/read/ReadingTimeEstimatorTest.kt | 248 ++++++++++ .../model/read/ReadingTimeIndexCodecTest.kt | 210 ++++++++ .../model/read/ReadingTimeReadConfigTest.kt | 69 +++ .../.openspec.yaml | 2 + .../design.md | 131 +++++ .../proposal.md | 36 ++ .../specs/reading-time-estimation/spec.md | 160 ++++++ .../tasks.md | 42 ++ .../specs/reading-time-estimation/spec.md | 162 ++++++ 39 files changed, 2860 insertions(+), 31 deletions(-) create mode 100644 app/src/main/java/io/legado/app/help/book/ReadingTimeIndexManager.kt create mode 100644 app/src/main/java/io/legado/app/model/read/ReadingTimeDisplayFormatter.kt create mode 100644 app/src/main/java/io/legado/app/model/read/ReadingTimeEstimator.kt create mode 100644 app/src/main/java/io/legado/app/model/read/ReadingTimeIndexCodec.kt create mode 100644 app/src/main/java/io/legado/app/model/read/ReadingTimeIndexReconciler.kt create mode 100644 app/src/test/java/io/legado/app/help/config/ReadTipConfigResourceTest.kt create mode 100644 app/src/test/java/io/legado/app/model/read/ReadingTimeEstimatorTest.kt create mode 100644 app/src/test/java/io/legado/app/model/read/ReadingTimeIndexCodecTest.kt create mode 100644 app/src/test/java/io/legado/app/model/read/ReadingTimeReadConfigTest.kt create mode 100644 openspec/changes/archive/2026-08-11-reading-time-estimation/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-11-reading-time-estimation/design.md create mode 100644 openspec/changes/archive/2026-08-11-reading-time-estimation/proposal.md create mode 100644 openspec/changes/archive/2026-08-11-reading-time-estimation/specs/reading-time-estimation/spec.md create mode 100644 openspec/changes/archive/2026-08-11-reading-time-estimation/tasks.md create mode 100644 openspec/specs/reading-time-estimation/spec.md diff --git a/app/src/main/java/io/legado/app/data/entities/Book.kt b/app/src/main/java/io/legado/app/data/entities/Book.kt index d0d411ff4..f0519e63d 100644 --- a/app/src/main/java/io/legado/app/data/entities/Book.kt +++ b/app/src/main/java/io/legado/app/data/entities/Book.kt @@ -21,6 +21,7 @@ import io.legado.app.help.book.simulatedTotalChapterNum import io.legado.app.help.config.AppConfig import io.legado.app.help.config.ReadBookConfig import io.legado.app.model.ReadBook +import io.legado.app.model.read.ReadingTimeState import io.legado.app.utils.GSON import io.legado.app.utils.fromJsonObject import kotlinx.parcelize.IgnoredOnParcel @@ -353,7 +354,7 @@ data class Book( newBook.customIntro = customIntro newBook.customTag = customTag newBook.canUpdate = canUpdate - newBook.readConfig = readConfig + newBook.readConfig = readConfig?.copy(readingTimeState = null) return newBook } @@ -402,7 +403,8 @@ data class Book( var readSimulating: Boolean = false, var startDate: LocalDate? = null, var startChapter: Int? = null, // 用户设置的起始章节 - var dailyChapters: Int = 3 // 用户设置的每日更新章节数 + var dailyChapters: Int = 3, // 用户设置的每日更新章节数 + var readingTimeState: ReadingTimeState? = null, ) : Parcelable class Converters { @@ -413,4 +415,4 @@ data class Book( @TypeConverter fun stringToReadConfig(json: String?) = GSON.fromJsonObject(json).getOrNull() } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/help/book/BookHelp.kt b/app/src/main/java/io/legado/app/help/book/BookHelp.kt index ea4a5b035..0b35d0f0a 100644 --- a/app/src/main/java/io/legado/app/help/book/BookHelp.kt +++ b/app/src/main/java/io/legado/app/help/book/BookHelp.kt @@ -63,16 +63,22 @@ object BookHelp { val cachePath = FileUtils.getPath(downloadDir, cacheFolderName) fun clearCache() { + ReadingTimeIndexManager.onAllCachesCleared() FileUtils.delete( FileUtils.getPath(downloadDir, cacheFolderName) ) } fun clearCache(book: Book) { + ReadingTimeIndexManager.onBookCacheCleared(book) val filePath = FileUtils.getPath(downloadDir, cacheFolderName, book.getFolderName()) FileUtils.delete(filePath) } + fun getBookCacheDirectory(book: Book): File { + return downloadDir.getFile(cacheFolderName, book.getFolderName()) + } + fun updateCacheFolder(oldBook: Book, newBook: Book) { val oldFolderName = oldBook.getFolderNameNoCache() val newFolderName = newBook.getFolderNameNoCache() @@ -180,12 +186,14 @@ object BookHelp { ) { if (content.isEmpty()) return //保存文本 - FileUtils.createFileIfNotExist( + val contentFile = FileUtils.createFileIfNotExist( downloadDir, cacheFolderName, book.getFolderName(), bookChapter.getFileName(), - ).writeText(content) + ) + contentFile.writeText(content) + ReadingTimeIndexManager.onContentSaved(book, bookChapter, contentFile.length()) if (book.isOnLineTxt && AppConfig.tocCountWords) { val wordCount = StringUtils.wordCountFormat(content.length) bookChapter.wordCount = wordCount @@ -430,6 +438,7 @@ object BookHelp { book.getFolderName(), bookChapter.getFileName() ).delete() + ReadingTimeIndexManager.onContentDeleted(book, bookChapter) } /** diff --git a/app/src/main/java/io/legado/app/help/book/ReadingTimeIndexManager.kt b/app/src/main/java/io/legado/app/help/book/ReadingTimeIndexManager.kt new file mode 100644 index 000000000..649491445 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/book/ReadingTimeIndexManager.kt @@ -0,0 +1,391 @@ +package io.legado.app.help.book + +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.model.localBook.LocalBook +import io.legado.app.model.read.ReadingTimeIdentity +import io.legado.app.model.read.ReadingTimeIndexCodec +import io.legado.app.model.read.ReadingTimeIndexData +import io.legado.app.model.read.ReadingTimeIndexReconciler +import io.legado.app.model.read.ReadingTimeIndexSnapshot +import io.legado.app.model.read.ReadingTimeState +import io.legado.app.model.read.ReadingTimeTocEntry +import java.io.File +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import kotlin.math.min + +data class ReadingTimeIndexUpdate( + val snapshot: ReadingTimeIndexSnapshot, + val resetSpeedModel: Boolean, + val bookIdentityHash: Long, + val tocChapterCount: Int, + val tocPrefixHash: Long, + val sourceLastModified: Long, +) + +object ReadingTimeIndexManager { + + const val INDEX_FILE_NAME = "reading_time_index.bin" + private const val SCAN_BATCH_SIZE = 64 + private const val SCAN_SLICE_NANOS = 8_000_000L + private const val SCAN_YIELD_MILLIS = 50L + private const val SNAPSHOT_DEBOUNCE_MILLIS = 500L + private const val WRITE_DEBOUNCE_MILLIS = 30_000L + + private val executor by lazy { + Executors.newSingleThreadScheduledExecutor { runnable -> + Thread(runnable, "reading-time-index").apply { + priority = Thread.MIN_PRIORITY + isDaemon = true + } + } + } + private var generation = 0L + private var session: Session? = null + + fun start( + book: Book, + chapters: List, + speedState: ReadingTimeState?, + onUpdate: (ReadingTimeIndexUpdate) -> Unit, + ) { + val request = StartRequest.create(book, chapters, speedState, onUpdate) + val requestGeneration: Long + val oldSession: Session? + synchronized(this) { + generation++ + requestGeneration = generation + oldSession = session + session = null + } + flush(oldSession) + executor.execute { initialize(requestGeneration, request) } + } + + fun stop() { + val oldSession: Session? + synchronized(this) { + generation++ + oldSession = session + session = null + } + flush(oldSession) + } + + fun onContentSaved(book: Book, chapter: BookChapter, rawBytes: Long) { + updateChapter(book, chapter, rawBytes.coerceIn(1L, Int.MAX_VALUE.toLong()).toInt()) + } + + fun onContentDeleted(book: Book, chapter: BookChapter) { + updateChapter(book, chapter, ReadingTimeIndexSnapshot.UNKNOWN_LENGTH) + } + + fun onBookCacheCleared(book: Book) { + synchronized(this) { + val current = session ?: return + if (!current.matches(book)) return + current.chapterMetadata.forEachIndexed { index, metadata -> + current.rawLengths[index] = metadata?.directRawLength + ?.takeIf { it >= 0 } ?: ReadingTimeIndexSnapshot.UNKNOWN_LENGTH + } + current.dirty = false + current.writeFuture?.cancel(false) + scheduleSnapshotLocked(current) + } + } + + fun onAllCachesCleared() { + synchronized(this) { + val current = session ?: return + current.chapterMetadata.forEachIndexed { index, metadata -> + current.rawLengths[index] = metadata?.directRawLength + ?.takeIf { it >= 0 } ?: ReadingTimeIndexSnapshot.UNKNOWN_LENGTH + } + current.dirty = false + current.writeFuture?.cancel(false) + scheduleSnapshotLocked(current) + } + } + + fun flushActive() { + val data = synchronized(this) { session?.toWriteRequest() } + if (data != null) { + executor.execute { ReadingTimeIndexCodec.write(data.file, data.data) } + } + } + + private fun initialize(requestGeneration: Long, request: StartRequest) { + if (!isCurrentGeneration(requestGeneration)) return + val storedFile = File(request.cacheDirectory, INDEX_FILE_NAME) + val stored = ReadingTimeIndexCodec.read(storedFile) + if (stored == null && storedFile.exists()) storedFile.delete() + val reconcile = ReadingTimeIndexReconciler.reconcile( + stored = stored, + bookIdentityHash = request.bookIdentityHash, + sourceLastModified = request.sourceLastModified, + entries = request.tocEntries, + ) + val resetStoredSpeed = ReadingTimeIndexReconciler.shouldResetSpeedState( + state = request.speedState, + bookIdentityHash = request.bookIdentityHash, + sourceLastModified = request.sourceLastModified, + entries = request.tocEntries, + ) + val newSession = Session( + generation = requestGeneration, + bookUrl = request.bookUrl, + cacheDirectory = request.cacheDirectory, + bookIdentityHash = request.bookIdentityHash, + sourceLastModified = request.sourceLastModified, + tocPrefixHash = reconcile.tocPrefixHash, + chapterMetadata = request.chapterMetadata, + rawLengths = reconcile.rawLengths, + onUpdate = request.onUpdate, + ) + synchronized(this) { + if (generation != requestGeneration) return + session = newSession + } + publish(newSession, reconcile.resetSpeedModel || resetStoredSpeed) + scanBatch(requestGeneration, 0, false) + } + + private fun scanBatch(requestGeneration: Long, startIndex: Int, previouslyChanged: Boolean) { + val current = synchronized(this) { + session?.takeIf { it.generation == requestGeneration } + } ?: return + val startedAt = System.nanoTime() + var index = startIndex + var changed = previouslyChanged + while (index < current.rawLengths.size && index - startIndex < SCAN_BATCH_SIZE) { + if (!isCurrentGeneration(requestGeneration)) return + val metadata = current.chapterMetadata[index] + if (metadata != null && current.rawLengths[index] < 0) { + val file = File(current.cacheDirectory, metadata.fileName) + if (file.isFile && file.length() > 0L) { + synchronized(this) { + val active = session + if (active?.generation != requestGeneration) return + active.rawLengths[index] = file.length() + .coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + active.dirty = true + } + changed = true + } + } + index++ + if (System.nanoTime() - startedAt >= SCAN_SLICE_NANOS) break + } + if (index < current.rawLengths.size) { + executor.schedule( + { scanBatch(requestGeneration, index, changed) }, + SCAN_YIELD_MILLIS, + TimeUnit.MILLISECONDS, + ) + } else if (changed) { + publishCurrent(requestGeneration, false) + synchronized(this) { + session?.takeIf { it.generation == requestGeneration }?.let(::scheduleWriteLocked) + } + } + } + + private fun updateChapter(book: Book, chapter: BookChapter, rawLength: Int) { + synchronized(this) { + val current = session ?: return + if (!current.matches(book) || chapter.index !in current.rawLengths.indices) { + return + } + val normalizedLength = if (chapter.isVolume) { + ReadingTimeIndexSnapshot.VOLUME_LENGTH + } else { + rawLength + } + if (current.rawLengths[chapter.index] == normalizedLength) return + current.rawLengths[chapter.index] = normalizedLength + current.dirty = true + scheduleSnapshotLocked(current) + scheduleWriteLocked(current) + } + } + + private fun scheduleSnapshotLocked(current: Session) { + current.snapshotFuture?.cancel(false) + current.snapshotFuture = executor.schedule( + { publishCurrent(current.generation, false) }, + SNAPSHOT_DEBOUNCE_MILLIS, + TimeUnit.MILLISECONDS, + ) + } + + private fun scheduleWriteLocked(current: Session) { + current.writeFuture?.cancel(false) + current.writeFuture = executor.schedule( + { writeCurrent(current.generation) }, + WRITE_DEBOUNCE_MILLIS, + TimeUnit.MILLISECONDS, + ) + } + + private fun publishCurrent(requestGeneration: Long, resetSpeedModel: Boolean) { + val current = synchronized(this) { + session?.takeIf { it.generation == requestGeneration } + } ?: return + publish(current, resetSpeedModel) + } + + private fun publish(current: Session, resetSpeedModel: Boolean) { + val lengths = synchronized(this) { + if (session?.generation != current.generation) return + current.rawLengths.copyOf() + } + current.onUpdate( + ReadingTimeIndexUpdate( + snapshot = ReadingTimeIndexSnapshot.create( + rawLengths = lengths, + bookIdentityHash = current.bookIdentityHash, + tocPrefixHash = current.tocPrefixHash, + ), + resetSpeedModel = resetSpeedModel, + bookIdentityHash = current.bookIdentityHash, + tocChapterCount = lengths.size, + tocPrefixHash = current.tocPrefixHash, + sourceLastModified = current.sourceLastModified, + ) + ) + } + + private fun writeCurrent(requestGeneration: Long) { + val request = synchronized(this) { + session?.takeIf { it.generation == requestGeneration }?.toWriteRequest() + } ?: return + if (ReadingTimeIndexCodec.write(request.file, request.data)) { + synchronized(this) { + session?.takeIf { it.generation == requestGeneration }?.dirty = false + } + } + } + + private fun flush(oldSession: Session?) { + if (oldSession == null || !oldSession.dirty) return + oldSession.snapshotFuture?.cancel(false) + oldSession.writeFuture?.cancel(false) + val request = oldSession.toWriteRequest() + executor.execute { ReadingTimeIndexCodec.write(request.file, request.data) } + } + + private fun isCurrentGeneration(requestGeneration: Long): Boolean { + return synchronized(this) { generation == requestGeneration } + } + + private data class ChapterMetadata( + val fileName: String, + val directRawLength: Int, + ) + + private data class StartRequest( + val bookUrl: String, + val cacheDirectory: File, + val bookIdentityHash: Long, + val sourceLastModified: Long, + val tocEntries: List, + val chapterMetadata: Array, + val speedState: ReadingTimeState?, + val onUpdate: (ReadingTimeIndexUpdate) -> Unit, + ) { + companion object { + fun create( + book: Book, + chapters: List, + speedState: ReadingTimeState?, + onUpdate: (ReadingTimeIndexUpdate) -> Unit, + ): StartRequest { + val chapterCount = maxOf( + chapters.size, + chapters.maxOfOrNull { it.index + 1 } ?: 0, + ) + val metadata = arrayOfNulls(chapterCount) + val entries = MutableList(chapterCount) { index -> + ReadingTimeTocEntry("$index|missing") + } + chapters.forEach { chapter -> + if (chapter.index !in 0 until chapterCount) return@forEach + val directLength = when { + chapter.isVolume -> ReadingTimeIndexSnapshot.VOLUME_LENGTH + book.isLocalTxt -> { + val start = chapter.start + val end = chapter.end + if (start != null && end != null && end > start) { + (end - start).coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + } else { + ReadingTimeIndexSnapshot.UNKNOWN_LENGTH + } + } + + else -> ReadingTimeIndexSnapshot.UNKNOWN_LENGTH + } + val fileName = chapter.getFileName() + metadata[chapter.index] = ChapterMetadata(fileName, directLength) + entries[chapter.index] = ReadingTimeTocEntry( + identity = "${chapter.index}|${chapter.url}|${chapter.title}|${chapter.isVolume}", + directRawLength = directLength, + ) + } + val sourceLastModified = if (book.isLocal) { + LocalBook.getLastModified(book).getOrDefault(0L) + } else { + 0L + } + return StartRequest( + bookUrl = book.bookUrl, + cacheDirectory = BookHelp.getBookCacheDirectory(book), + bookIdentityHash = ReadingTimeIdentity.hash(listOf(book.bookUrl, book.origin)), + sourceLastModified = sourceLastModified, + tocEntries = entries, + chapterMetadata = metadata, + speedState = speedState?.copy(), + onUpdate = onUpdate, + ) + } + } + } + + private data class Session( + val generation: Long, + val bookUrl: String, + val cacheDirectory: File, + val bookIdentityHash: Long, + val sourceLastModified: Long, + val tocPrefixHash: Long, + val chapterMetadata: Array, + val rawLengths: IntArray, + val onUpdate: (ReadingTimeIndexUpdate) -> Unit, + var dirty: Boolean = false, + var snapshotFuture: ScheduledFuture<*>? = null, + var writeFuture: ScheduledFuture<*>? = null, + ) { + fun matches(book: Book): Boolean { + return bookUrl == book.bookUrl && + bookIdentityHash == ReadingTimeIdentity.hash(listOf(book.bookUrl, book.origin)) + } + + fun toWriteRequest(): WriteRequest { + return WriteRequest( + file = File(cacheDirectory, INDEX_FILE_NAME), + data = ReadingTimeIndexData( + bookIdentityHash = bookIdentityHash, + tocPrefixHash = tocPrefixHash, + sourceLastModified = sourceLastModified, + rawLengths = rawLengths.copyOf(), + ), + ) + } + } + + private data class WriteRequest( + val file: File, + val data: ReadingTimeIndexData, + ) +} diff --git a/app/src/main/java/io/legado/app/help/config/ReadTipConfig.kt b/app/src/main/java/io/legado/app/help/config/ReadTipConfig.kt index dc36ffae0..8b527160a 100644 --- a/app/src/main/java/io/legado/app/help/config/ReadTipConfig.kt +++ b/app/src/main/java/io/legado/app/help/config/ReadTipConfig.kt @@ -19,10 +19,14 @@ object ReadTipConfig { const val timeBattery = 8 const val timeBatteryPercentage = 9 const val totalProgress1 = 11 + const val readTime = 12 + const val remainingReadTime = 13 + const val readAndRemainingTime = 14 val tipValues = arrayOf( none, bookName, chapterTitle, time, battery, batteryPercentage, page, - totalProgress, totalProgress1, pageAndTotal, timeBattery, timeBatteryPercentage + totalProgress, totalProgress1, pageAndTotal, timeBattery, timeBatteryPercentage, + readTime, remainingReadTime, readAndRemainingTime ) val tipNames get() = appCtx.resources.getStringArray(R.array.read_tip).toList() @@ -30,6 +34,19 @@ object ReadTipConfig { val tipDividerColorNames get() = appCtx.resources.getStringArray(R.array.tip_divider_color).toList() + val hasReadingTimeTip: Boolean + get() = readingTimeTips.any(::isTipSelected) + + val hasRemainingReadTimeTip: Boolean + get() = isTipSelected(remainingReadTime) || isTipSelected(readAndRemainingTime) + + private val readingTimeTips = intArrayOf(readTime, remainingReadTime, readAndRemainingTime) + + private fun isTipSelected(tip: Int): Boolean { + return tipHeaderLeft == tip || tipHeaderMiddle == tip || tipHeaderRight == tip || + tipFooterLeft == tip || tipFooterMiddle == tip || tipFooterRight == tip + } + var tipHeaderLeft: Int get() = ReadBookConfig.config.tipHeaderLeft set(value) { @@ -104,4 +121,4 @@ object ReadTipConfig { Pair(1, context.getString(R.string.hide)) ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/model/ReadBook.kt b/app/src/main/java/io/legado/app/model/ReadBook.kt index a20ae7ffe..5ace01ec5 100644 --- a/app/src/main/java/io/legado/app/model/ReadBook.kt +++ b/app/src/main/java/io/legado/app/model/ReadBook.kt @@ -1,5 +1,6 @@ package io.legado.app.model +import android.os.SystemClock import io.legado.app.constant.AppLog import io.legado.app.constant.EventBus import io.legado.app.constant.PageAnim.scrollPageAnim @@ -12,6 +13,9 @@ import io.legado.app.data.entities.ReadRecord import io.legado.app.help.AppWebDav import io.legado.app.help.book.BookHelp import io.legado.app.help.book.ContentProcessor +import io.legado.app.help.book.ReadingTimeIndexManager +import io.legado.app.help.book.ReadingTimeIndexUpdate +import io.legado.app.help.book.isAudio import io.legado.app.help.book.isImage import io.legado.app.help.book.isLocal import io.legado.app.help.book.isPdf @@ -21,9 +25,17 @@ import io.legado.app.help.book.simulatedTotalChapterNum import io.legado.app.help.book.update import io.legado.app.help.config.AppConfig import io.legado.app.help.config.ReadBookConfig +import io.legado.app.help.config.ReadTipConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.globalExecutor import io.legado.app.model.localBook.TextFile +import io.legado.app.model.read.ReadingTimeAdvanceResult +import io.legado.app.model.read.ReadingTimeDisplayFormatter +import io.legado.app.model.read.ReadingTimeDisplaySnapshot +import io.legado.app.model.read.ReadingTimeEstimate +import io.legado.app.model.read.ReadingTimeEstimator +import io.legado.app.model.read.ReadingTimeIndexSnapshot +import io.legado.app.model.read.ReadingTimePosition import io.legado.app.model.webBook.WebBook import io.legado.app.service.BaseReadAloudService import io.legado.app.service.CacheBookService @@ -59,6 +71,8 @@ import kotlin.math.min @Suppress("MemberVisibilityCanBePrivate") object ReadBook : CoroutineScope by MainScope() { + private const val READING_TIME_PERSIST_INTERVAL = 120_000L + var book: Book? = null var callBack: CallBack? = null var inBookshelf = false @@ -80,6 +94,29 @@ object ReadBook : CoroutineScope by MainScope() { private val curChapterLoadingLock = Mutex() private val nextChapterLoadingLock = Mutex() var readStartTime: Long = System.currentTimeMillis() + private var readingTimeEstimator = ReadingTimeEstimator() + private var readingTimeEstimate: ReadingTimeEstimate = ReadingTimeEstimate.Unavailable + private var readingTimeStateDirty = false + private var readingTimeLastPersistElapsed = 0L + private var readingTimeHostResumed = false + private var readingTimeMenuVisible = false + private var readingTimeLayoutChanging = true + private var readingTimeSamplingActive = false + private var readingTimeBookIdentityHash = 0L + private var readingTimeTocChapterCount = 0 + private var readingTimeTocPrefixHash = 0L + private var readingTimeSourceLastModified = 0L + private var readingTimeIndexJob: Job? = null + private var readingTimeIndexGeneration = 0L + + @Volatile + var readingTimeDisplay = ReadingTimeDisplayFormatter.format( + appCtx, + readRecordEnabled = false, + accumulatedReadMillis = 0L, + estimate = ReadingTimeEstimate.Unavailable, + ) + private set /* 跳转进度前进度记录 */ var lastBookProgress: BookProgress? = null @@ -98,8 +135,10 @@ object ReadBook : CoroutineScope by MainScope() { fun resetData(book: Book) { releaseAndCancel() ReadBook.book = book - readRecord.bookName = book.name - readRecord.readTime = appDb.readRecordDao.getReadTime(book.name) ?: 0 + synchronized(readRecord) { + readRecord.bookName = book.name + readRecord.readTime = appDb.readRecordDao.getReadTime(book.name) ?: 0 + } chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl) simulatedChapterSize = if (book.readSimulating()) { book.simulatedTotalChapterNum() @@ -111,6 +150,7 @@ object ReadBook : CoroutineScope by MainScope() { durChapterPos = book.durChapterPos isLocalBook = book.isLocal clearTextChapter() + initializeReadingTime(book) callBack?.upContent() callBack?.upMenuView() callBack?.upPageAnim() @@ -148,6 +188,7 @@ object ReadBook : CoroutineScope by MainScope() { if (prevTextChapter?.isCompleted == false) { prevTextChapter = null } + initializeReadingTime(book) callBack?.upMenuView() upWebBook(book) synchronized(this) { @@ -193,6 +234,333 @@ object ReadBook : CoroutineScope by MainScope() { } } + private fun initializeReadingTime(book: Book) { + readingTimeIndexJob?.cancel() + ReadingTimeIndexManager.stop() + readingTimeEstimator = ReadingTimeEstimator(book.config.readingTimeState) + readingTimeEstimator.updateIndex(ReadingTimeIndexSnapshot.empty(chapterSize)) + readingTimeEstimate = if (AppConfig.enableReadRecord && isReadingTimeSupported(book)) { + readingTimeEstimator.estimate(currentReadingTimePosition()) + } else { + ReadingTimeEstimate.Unavailable + } + readingTimeStateDirty = false + readingTimeLastPersistElapsed = SystemClock.elapsedRealtime() + readingTimeSamplingActive = false + readingTimeLayoutChanging = curTextChapter?.isCompleted != true + val state = book.config.readingTimeState + readingTimeBookIdentityHash = state?.bookIdentityHash ?: 0L + readingTimeTocChapterCount = state?.tocChapterCount ?: 0 + readingTimeTocPrefixHash = state?.tocPrefixHash ?: 0L + readingTimeSourceLastModified = state?.sourceLastModified ?: 0L + refreshReadingTimeDisplay() + onReadingTimeTipConfigChanged() + } + + private fun isReadingTimeSupported(book: Book): Boolean { + return !book.isAudio && !book.isImage && !book.isPdf + } + + private fun shouldBuildReadingTimeIndex(): Boolean { + val book = book ?: return false + return ReadTipConfig.hasRemainingReadTimeTip && isReadingTimeSupported(book) + } + + private fun canTrainReadingTime(): Boolean { + val book = book ?: return false + return AppConfig.enableReadRecord && + shouldBuildReadingTimeIndex() && + isReadingTimeSupported(book) && + readingTimeHostResumed && + !readingTimeMenuVisible && + !readingTimeLayoutChanging && + !BaseReadAloudService.isRun + } + + private fun currentReadingTimePosition(): ReadingTimePosition { + val safeChapterIndex = if (chapterSize > 0) { + durChapterIndex.coerceIn(0, chapterSize - 1) + } else { + 0 + } + val textChapter = curTextChapter + val progress = if ( + textChapter?.position == safeChapterIndex && + textChapter.isCompleted && + textChapter.pageSize > 0 + ) { + val page = textChapter.getPageByReadPos(durChapterPos) + val lastPage = textChapter.lastPage + if (page != null && lastPage != null) { + val pageEnd = page.chapterPosition.toLong() + page.charSize + val chapterEnd = lastPage.chapterPosition.toLong() + lastPage.charSize + if (chapterEnd > 0L) pageEnd.toDouble() / chapterEnd else 0.0 + } else { + 0.0 + } + } else { + 0.0 + } + return ReadingTimePosition(safeChapterIndex, progress.coerceIn(0.0, 1.0)) + } + + private fun resumeReadingTimeSampling() { + if (!canTrainReadingTime()) { + pauseReadingTimeSampling() + return + } + readingTimeEstimator.resume( + currentReadingTimePosition(), + SystemClock.elapsedRealtime(), + ) + readingTimeSamplingActive = true + } + + private fun pauseReadingTimeSampling() { + if (readingTimeSamplingActive) { + readingTimeEstimator.pause() + readingTimeSamplingActive = false + } + } + + private fun onReadingTimePositionChanged(allowTraining: Boolean) { + val position = currentReadingTimePosition() + if (!canTrainReadingTime()) { + pauseReadingTimeSampling() + readingTimeEstimate = ReadingTimeEstimate.Unavailable.takeIf { + !AppConfig.enableReadRecord || book?.let(::isReadingTimeSupported) != true + } ?: readingTimeEstimator.estimate(position) + refreshReadingTimeDisplay() + return + } + if (!readingTimeSamplingActive) { + readingTimeEstimator.resume(position, SystemClock.elapsedRealtime()) + readingTimeSamplingActive = true + readingTimeEstimate = readingTimeEstimator.estimate(position) + refreshReadingTimeDisplay() + return + } + if (allowTraining) { + val result: ReadingTimeAdvanceResult = readingTimeEstimator.onForward( + position = position, + allowTraining = true, + nowMillis = SystemClock.elapsedRealtime(), + ) + readingTimeEstimate = result.estimate + if (result.sampleAccepted) { + readingTimeStateDirty = true + persistReadingTimeState(force = false) + } + } else { + readingTimeEstimator.reanchor(position, SystemClock.elapsedRealtime()) + readingTimeEstimate = readingTimeEstimator.estimate(position) + } + refreshReadingTimeDisplay() + } + + fun onReadingTimeResumed() { + readingTimeHostResumed = true + resumeReadingTimeSampling() + refreshReadingTimeDisplay() + } + + fun onReadingTimePaused() { + refreshReadingTimeDisplay() + readingTimeHostResumed = false + pauseReadingTimeSampling() + persistReadingTimeState(force = true) + ReadingTimeIndexManager.flushActive() + } + + fun onReadingTimeMenuVisibilityChanged(visible: Boolean) { + readingTimeMenuVisible = visible + if (visible) { + pauseReadingTimeSampling() + } else { + resumeReadingTimeSampling() + } + } + + fun onReadingTimeLayoutChanged() { + readingTimeLayoutChanging = true + pauseReadingTimeSampling() + } + + private fun onReadingTimeLayoutReady() { + readingTimeLayoutChanging = false + readingTimeEstimate = if (AppConfig.enableReadRecord) { + readingTimeEstimator.estimate(currentReadingTimePosition()) + } else { + ReadingTimeEstimate.Unavailable + } + resumeReadingTimeSampling() + refreshReadingTimeDisplay() + } + + fun onReadingTimeAloudStateChanged() { + if (BaseReadAloudService.isRun) { + pauseReadingTimeSampling() + } else { + resumeReadingTimeSampling() + } + } + + fun onReadingTimeTipConfigChanged() { + val requestGeneration = synchronized(this) { ++readingTimeIndexGeneration } + readingTimeIndexJob?.cancel() + if (!shouldBuildReadingTimeIndex()) { + ReadingTimeIndexManager.stop() + pauseReadingTimeSampling() + readingTimeEstimate = ReadingTimeEstimate.Unavailable + refreshReadingTimeDisplay() + return + } + val currentBook = book ?: return + val speedState = readingTimeEstimator.stateSnapshot() + readingTimeIndexJob = launch(IO) { + val chapters = appDb.bookChapterDao.getChapterList(currentBook.bookUrl) + if (!isCurrentReadingTimeIndexRequest(requestGeneration) || + book?.bookUrl != currentBook.bookUrl || + !shouldBuildReadingTimeIndex() + ) { + return@launch + } + ReadingTimeIndexManager.start( + currentBook, + chapters, + speedState, + ) { update -> + postReadingTimeIndexUpdate( + currentBook.bookUrl, + requestGeneration, + update, + ) + } + } + readingTimeEstimate = readingTimeEstimator.estimate(currentReadingTimePosition()) + resumeReadingTimeSampling() + refreshReadingTimeDisplay() + } + + private fun isCurrentReadingTimeIndexRequest(requestGeneration: Long): Boolean { + return synchronized(this) { readingTimeIndexGeneration == requestGeneration } + } + + private fun postReadingTimeIndexUpdate( + bookUrl: String, + requestGeneration: Long, + update: ReadingTimeIndexUpdate, + ) { + launch(Main) { + applyReadingTimeIndexUpdate(bookUrl, requestGeneration, update) + } + } + + private fun applyReadingTimeIndexUpdate( + bookUrl: String, + requestGeneration: Long, + update: ReadingTimeIndexUpdate, + ) { + if (!isCurrentReadingTimeIndexRequest(requestGeneration) || + book?.bookUrl != bookUrl || + !shouldBuildReadingTimeIndex() + ) { + return + } + val position = currentReadingTimePosition() + if (update.resetSpeedModel) { + readingTimeEstimator.reset(position, SystemClock.elapsedRealtime()) + readingTimeStateDirty = true + } + readingTimeEstimator.updateIdentity( + update.bookIdentityHash, + update.tocChapterCount, + update.tocPrefixHash, + update.sourceLastModified, + ) + if (readingTimeBookIdentityHash != update.bookIdentityHash || + readingTimeTocChapterCount != update.tocChapterCount || + readingTimeTocPrefixHash != update.tocPrefixHash || + readingTimeSourceLastModified != update.sourceLastModified + ) { + readingTimeStateDirty = true + } + readingTimeBookIdentityHash = update.bookIdentityHash + readingTimeTocChapterCount = update.tocChapterCount + readingTimeTocPrefixHash = update.tocPrefixHash + readingTimeSourceLastModified = update.sourceLastModified + readingTimeEstimator.updateIndex(update.snapshot) + readingTimeEstimate = if (AppConfig.enableReadRecord) { + readingTimeEstimator.estimate(position) + } else { + ReadingTimeEstimate.Unavailable + } + refreshReadingTimeDisplay() + } + + fun resetReadingTimeEstimation(): Boolean { + if (book == null) return false + val position = currentReadingTimePosition() + readingTimeEstimator.reset(position, SystemClock.elapsedRealtime()) + readingTimeEstimator.updateIdentity( + readingTimeBookIdentityHash, + readingTimeTocChapterCount, + readingTimeTocPrefixHash, + readingTimeSourceLastModified, + ) + readingTimeStateDirty = true + readingTimeEstimate = if (AppConfig.enableReadRecord && shouldBuildReadingTimeIndex()) { + readingTimeEstimator.estimate(position) + } else { + ReadingTimeEstimate.Unavailable + } + persistReadingTimeState(force = true) + refreshReadingTimeDisplay() + return true + } + + fun refreshReadingTimeDisplay() { + if (!AppConfig.enableReadRecord) { + pauseReadingTimeSampling() + readingTimeEstimate = ReadingTimeEstimate.Unavailable + } else if (!readingTimeSamplingActive) { + resumeReadingTimeSampling() + } + val accumulatedReadMillis = synchronized(readRecord) { + val currentSegment = if (readingTimeHostResumed) { + (System.currentTimeMillis() - readStartTime).coerceAtLeast(0L) + } else { + 0L + } + readRecord.readTime + currentSegment + } + readingTimeDisplay = ReadingTimeDisplayFormatter.format( + appCtx, + AppConfig.enableReadRecord, + accumulatedReadMillis, + readingTimeEstimate, + ) + } + + private fun persistReadingTimeState(force: Boolean) { + if (!readingTimeStateDirty) return + val now = SystemClock.elapsedRealtime() + if (!force && now - readingTimeLastPersistElapsed < READING_TIME_PERSIST_INTERVAL) return + val currentBook = book ?: return + readingTimeEstimator.updateIdentity( + readingTimeBookIdentityHash, + readingTimeTocChapterCount, + readingTimeTocPrefixHash, + readingTimeSourceLastModified, + ) + currentBook.config.readingTimeState = readingTimeEstimator.stateSnapshot() + readingTimeStateDirty = false + readingTimeLastPersistElapsed = now + executor.execute { + appDb.bookDao.update(currentBook) + } + } + fun setProgress(progress: BookProgress) { if (progress.durChapterIndex < chapterSize && (durChapterIndex != progress.durChapterIndex @@ -200,6 +568,7 @@ object ReadBook : CoroutineScope by MainScope() { ) { durChapterIndex = progress.durChapterIndex durChapterPos = progress.durChapterPos + onReadingTimePositionChanged(false) saveRead() clearTextChapter() callBack?.upContent() @@ -222,6 +591,7 @@ object ReadBook : CoroutineScope by MainScope() { } fun clearTextChapter() { + onReadingTimeLayoutChanged() clearExpiredChapterLoadingJob(true) prevTextChapter = null curTextChapter = null @@ -283,14 +653,16 @@ object ReadBook : CoroutineScope by MainScope() { } fun upReadTime() { + if (!AppConfig.enableReadRecord) return + val recordSnapshot = synchronized(readRecord) { + val now = System.currentTimeMillis() + readRecord.readTime = readRecord.readTime + now - readStartTime + readStartTime = now + readRecord.lastRead = now + readRecord.copy() + } executor.execute { - if (!AppConfig.enableReadRecord) { - return@execute - } - readRecord.readTime = readRecord.readTime + System.currentTimeMillis() - readStartTime - readStartTime = System.currentTimeMillis() - readRecord.lastRead = System.currentTimeMillis() - appDb.readRecordDao.insert(readRecord) + appDb.readRecordDao.insert(recordSnapshot) } } @@ -309,6 +681,7 @@ object ReadBook : CoroutineScope by MainScope() { hasNextPage = true it.getPage(durPageIndex)?.removePageAloudSpan() durChapterPos = nextPagePos + onReadingTimePositionChanged(false) callBack?.cancelSelect() callBack?.upContent() saveRead(true) @@ -324,6 +697,7 @@ object ReadBook : CoroutineScope by MainScope() { if (prevPagePos >= 0) { hasPrevPage = true durChapterPos = prevPagePos + onReadingTimePositionChanged(false) callBack?.upContent() saveRead(true) } @@ -331,7 +705,11 @@ object ReadBook : CoroutineScope by MainScope() { return hasPrevPage } - fun moveToNextChapter(upContent: Boolean, upContentInPlace: Boolean = true): Boolean { + fun moveToNextChapter( + upContent: Boolean, + upContentInPlace: Boolean = true, + allowReadingTimeTraining: Boolean = false, + ): Boolean { if (durChapterIndex < simulatedChapterSize - 1) { durChapterPos = 0 durChapterIndex++ @@ -339,6 +717,7 @@ object ReadBook : CoroutineScope by MainScope() { prevTextChapter = curTextChapter curTextChapter = nextTextChapter nextTextChapter = null + onReadingTimePositionChanged(allowReadingTimeTraining) if (curTextChapter == null) { AppLog.putDebug("moveToNextChapter-章节未加载,开始加载") if (upContentInPlace) callBack?.upContent() @@ -361,7 +740,8 @@ object ReadBook : CoroutineScope by MainScope() { suspend fun moveToNextChapterAwait( upContent: Boolean, - upContentInPlace: Boolean = true + upContentInPlace: Boolean = true, + allowReadingTimeTraining: Boolean = false, ): Boolean { if (durChapterIndex < simulatedChapterSize - 1) { durChapterPos = 0 @@ -370,6 +750,7 @@ object ReadBook : CoroutineScope by MainScope() { prevTextChapter = curTextChapter curTextChapter = nextTextChapter nextTextChapter = null + onReadingTimePositionChanged(allowReadingTimeTraining) if (curTextChapter == null) { AppLog.putDebug("moveToNextChapter-章节未加载,开始加载") if (upContentInPlace) callBack?.upContentAwait() @@ -402,6 +783,7 @@ object ReadBook : CoroutineScope by MainScope() { nextTextChapter = curTextChapter curTextChapter = prevTextChapter prevTextChapter = null + onReadingTimePositionChanged(false) if (curTextChapter == null) { if (upContentInPlace) callBack?.upContent() loadContent(durChapterIndex, upContent, resetPageOffset = false) @@ -420,6 +802,7 @@ object ReadBook : CoroutineScope by MainScope() { fun skipToPage(index: Int, success: (() -> Unit)? = null) { durChapterPos = curTextChapter?.getReadLength(index) ?: index + onReadingTimePositionChanged(false) callBack?.upContent { success?.invoke() } @@ -427,9 +810,10 @@ object ReadBook : CoroutineScope by MainScope() { saveRead(true) } - fun setPageIndex(index: Int) { + fun setPageIndex(index: Int, allowReadingTimeTraining: Boolean = false) { recycleRecorders(durPageIndex, index) durChapterPos = curTextChapter?.getReadLength(index) ?: index + onReadingTimePositionChanged(allowReadingTimeTraining) saveRead(true) curPageChanged(true) } @@ -460,6 +844,7 @@ object ReadBook : CoroutineScope by MainScope() { if (upContent) callBack?.upContent() durChapterIndex = index ReadBook.durChapterPos = durChapterPos + onReadingTimePositionChanged(false) saveRead() loadContent(resetPageOffset = true) { success?.invoke() @@ -738,6 +1123,9 @@ object ReadBook : CoroutineScope by MainScope() { callBack?.onLayoutPageCompleted(index, page) } if (upContent) callBack?.upContent(offset, !available && resetPageOffset) + withContext(Main) { + onReadingTimeLayoutReady() + } curPageChanged() callBack?.contentLoadFinish() } @@ -825,6 +1213,9 @@ object ReadBook : CoroutineScope by MainScope() { callBack?.onLayoutPageCompleted(index, page) } if (upContent) callBack?.upContent(offset, !available && resetPageOffset) + withContext(Main) { + onReadingTimeLayoutReady() + } curPageChanged() callBack?.contentLoadFinish() } @@ -971,6 +1362,8 @@ object ReadBook : CoroutineScope by MainScope() { if (simulatedChapterSize > 0 && durChapterIndex > simulatedChapterSize - 1) { durChapterIndex = simulatedChapterSize - 1 } + onReadingTimePositionChanged(false) + onReadingTimeTipConfigChanged() callBack?.upMenuView() if (callBack == null) { clearTextChapter() @@ -1010,6 +1403,12 @@ object ReadBook : CoroutineScope by MainScope() { } private fun releaseAndCancel() { + synchronized(this) { readingTimeIndexGeneration++ } + persistReadingTimeState(force = true) + pauseReadingTimeSampling() + readingTimeIndexJob?.cancel() + readingTimeIndexJob = null + ReadingTimeIndexManager.stop() msg = null preDownloadTask?.cancel() downloadScope.coroutineContext.cancelChildren() diff --git a/app/src/main/java/io/legado/app/model/read/ReadingTimeDisplayFormatter.kt b/app/src/main/java/io/legado/app/model/read/ReadingTimeDisplayFormatter.kt new file mode 100644 index 000000000..58feebfa2 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/read/ReadingTimeDisplayFormatter.kt @@ -0,0 +1,70 @@ +package io.legado.app.model.read + +import android.content.Context +import io.legado.app.R + +data class ReadingTimeDisplaySnapshot( + val accumulated: String, + val remaining: String, + val combined: String, +) + +object ReadingTimeDisplayFormatter { + + fun format( + context: Context, + readRecordEnabled: Boolean, + accumulatedReadMillis: Long, + estimate: ReadingTimeEstimate, + ): ReadingTimeDisplaySnapshot { + val unavailable = context.getString(R.string.reading_time_unavailable) + if (!readRecordEnabled) { + return unavailableSnapshot(unavailable) + } + val accumulated = formatAccumulated(context, accumulatedReadMillis) + val remaining = when (estimate) { + ReadingTimeEstimate.Unavailable -> unavailable + ReadingTimeEstimate.Learning -> context.getString(R.string.reading_time_learning) + is ReadingTimeEstimate.Ready -> formatRemaining(context, estimate.remainingSeconds) + } + return ReadingTimeDisplaySnapshot( + accumulated = accumulated, + remaining = remaining, + combined = context.getString(R.string.reading_time_combined, accumulated, remaining), + ) + } + + internal fun unavailableSnapshot(unavailable: String): ReadingTimeDisplaySnapshot { + return ReadingTimeDisplaySnapshot(unavailable, unavailable, unavailable) + } + + private fun formatAccumulated(context: Context, readMillis: Long): String { + val parts = ReadingTimeDuration.splitMinutes( + ReadingTimeDuration.accumulatedMinutes(readMillis) + ) + return if (parts.hours > 0L) { + context.getString( + R.string.reading_time_read_hours_minutes, + parts.hours, + parts.minutes, + ) + } else { + context.getString(R.string.reading_time_read_minutes, parts.minutes) + } + } + + private fun formatRemaining(context: Context, remainingSeconds: Double): String { + val parts = ReadingTimeDuration.splitMinutes( + ReadingTimeDuration.remainingMinutes(remainingSeconds) + ) + return if (parts.hours > 0L) { + context.getString( + R.string.reading_time_remaining_hours_minutes, + parts.hours, + parts.minutes, + ) + } else { + context.getString(R.string.reading_time_remaining_minutes, parts.minutes) + } + } +} diff --git a/app/src/main/java/io/legado/app/model/read/ReadingTimeEstimator.kt b/app/src/main/java/io/legado/app/model/read/ReadingTimeEstimator.kt new file mode 100644 index 000000000..9e829b91d --- /dev/null +++ b/app/src/main/java/io/legado/app/model/read/ReadingTimeEstimator.kt @@ -0,0 +1,462 @@ +package io.legado.app.model.read + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import kotlin.math.ceil +import kotlin.math.max +import kotlin.math.min + +@Parcelize +data class ReadingTimeState( + var version: Int = CURRENT_VERSION, + var chapterSecondsPerUnit: Double = 0.0, + var contentSecondsPerByte: Double = 0.0, + var sampleCount: Int = 0, + var contentSampleCount: Int = 0, + var validReadingMillis: Long = 0L, + var contentValidReadingMillis: Long = 0L, + var bookIdentityHash: Long = 0L, + var tocChapterCount: Int = 0, + var tocPrefixHash: Long = 0L, + var sourceLastModified: Long = 0L, +) : Parcelable { + + fun isChapterQualified(): Boolean { + return version == CURRENT_VERSION && + sampleCount >= MIN_SAMPLE_COUNT && + validReadingMillis >= MIN_VALID_READING_MILLIS && + chapterSecondsPerUnit > 0.0 + } + + fun isContentQualified(): Boolean { + return version == CURRENT_VERSION && + contentSampleCount >= MIN_SAMPLE_COUNT && + contentValidReadingMillis >= MIN_VALID_READING_MILLIS && + contentSecondsPerByte > 0.0 + } + + companion object { + const val CURRENT_VERSION = 1 + const val MIN_SAMPLE_COUNT = 5 + const val MIN_VALID_READING_MILLIS = 60_000L + } +} + +data class ReadingTimePosition( + val chapterIndex: Int, + val chapterProgress: Double, +) { + val normalizedProgress: Double + get() = chapterProgress.coerceIn(0.0, 1.0) + + val chapterCoordinate: Double + get() = chapterIndex.coerceAtLeast(0) + normalizedProgress +} + +enum class ReadingTimeEstimateMode { + CHAPTER, + HYBRID_CONTENT, + FULL_CONTENT, +} + +sealed class ReadingTimeEstimate { + data object Unavailable : ReadingTimeEstimate() + data object Learning : ReadingTimeEstimate() + data class Ready( + val remainingSeconds: Double, + val mode: ReadingTimeEstimateMode, + ) : ReadingTimeEstimate() +} + +data class ReadingTimeAdvanceResult( + val estimate: ReadingTimeEstimate, + val sampleAccepted: Boolean, +) + +class ReadingTimeIndexSnapshot private constructor( + val rawLengths: IntArray, + private val knownBytePrefix: LongArray, + private val knownCountPrefix: IntArray, + private val contentCountPrefix: IntArray, + val medianRawLength: Int, + val knownContentCount: Int, + val contentChapterCount: Int, + val mode: ReadingTimeEstimateMode, + val bookIdentityHash: Long, + val tocPrefixHash: Long, +) { + + val chapterCount: Int + get() = rawLengths.size + + fun remainingChapterUnits(position: ReadingTimePosition): Double { + if (chapterCount == 0) return 0.0 + return max(0.0, chapterCount - position.chapterCoordinate) + } + + fun contentCoordinate(position: ReadingTimePosition): Double? { + val index = position.chapterIndex + if (index !in rawLengths.indices) return null + val chapterLength = rawLengths[index] + if (chapterLength <= 0) return null + return knownBytePrefix[index] + chapterLength * position.normalizedProgress + } + + fun hasUnknownContentBetween(startChapter: Int, endChapter: Int): Boolean { + if (startChapter !in rawLengths.indices || endChapter !in rawLengths.indices) { + return true + } + val start = min(startChapter, endChapter) + val endExclusive = max(startChapter, endChapter) + 1 + val contentCount = contentCountPrefix[endExclusive] - contentCountPrefix[start] + val knownCount = knownCountPrefix[endExclusive] - knownCountPrefix[start] + return knownCount != contentCount + } + + fun remainingContentBytes(position: ReadingTimePosition): Double? { + if (mode == ReadingTimeEstimateMode.CHAPTER || contentChapterCount == 0) return null + val index = position.chapterIndex + if (index !in rawLengths.indices) return null + val currentLength = estimatedLength(index) + val currentRemaining = currentLength * (1.0 - position.normalizedProgress) + val afterIndex = index + 1 + val knownAfter = knownBytePrefix[chapterCount] - knownBytePrefix[afterIndex] + val contentAfter = contentCountPrefix[chapterCount] - contentCountPrefix[afterIndex] + val knownCountAfter = knownCountPrefix[chapterCount] - knownCountPrefix[afterIndex] + val unknownAfter = contentAfter - knownCountAfter + return max(0.0, currentRemaining + knownAfter + unknownAfter * medianRawLength.toDouble()) + } + + private fun estimatedLength(index: Int): Double { + val length = rawLengths[index] + return when { + length > 0 -> length.toDouble() + length == 0 -> 0.0 + else -> medianRawLength.toDouble() + } + } + + companion object { + const val UNKNOWN_LENGTH = -1 + const val VOLUME_LENGTH = 0 + const val MIN_HYBRID_CHAPTERS = 20 + const val MIN_HYBRID_COVERAGE = 0.2 + + fun empty(chapterCount: Int = 0): ReadingTimeIndexSnapshot { + return create(IntArray(chapterCount.coerceAtLeast(0)) { UNKNOWN_LENGTH }) + } + + fun create( + rawLengths: IntArray, + bookIdentityHash: Long = 0L, + tocPrefixHash: Long = 0L, + ): ReadingTimeIndexSnapshot { + val lengths = rawLengths.copyOf() + val knownBytePrefix = LongArray(lengths.size + 1) + val knownCountPrefix = IntArray(lengths.size + 1) + val contentCountPrefix = IntArray(lengths.size + 1) + val knownLengths = ArrayList() + lengths.forEachIndexed { index, length -> + val normalizedLength = if (length < UNKNOWN_LENGTH) UNKNOWN_LENGTH else length + lengths[index] = normalizedLength + knownBytePrefix[index + 1] = knownBytePrefix[index] + knownCountPrefix[index + 1] = knownCountPrefix[index] + contentCountPrefix[index + 1] = contentCountPrefix[index] + if (normalizedLength != VOLUME_LENGTH) { + contentCountPrefix[index + 1]++ + } + if (normalizedLength > 0) { + knownBytePrefix[index + 1] += normalizedLength.toLong() + knownCountPrefix[index + 1]++ + knownLengths.add(normalizedLength) + } + } + knownLengths.sort() + val median = when { + knownLengths.isEmpty() -> 0 + knownLengths.size % 2 == 1 -> knownLengths[knownLengths.size / 2] + else -> { + val right = knownLengths.size / 2 + ((knownLengths[right - 1].toLong() + knownLengths[right]) / 2L).toInt() + } + } + val knownCount = knownCountPrefix.last() + val contentCount = contentCountPrefix.last() + val mode = when { + contentCount > 0 && knownCount == contentCount -> ReadingTimeEstimateMode.FULL_CONTENT + knownCount >= MIN_HYBRID_CHAPTERS && + knownCount.toDouble() / contentCount.coerceAtLeast(1) >= MIN_HYBRID_COVERAGE -> { + ReadingTimeEstimateMode.HYBRID_CONTENT + } + + else -> ReadingTimeEstimateMode.CHAPTER + } + return ReadingTimeIndexSnapshot( + rawLengths = lengths, + knownBytePrefix = knownBytePrefix, + knownCountPrefix = knownCountPrefix, + contentCountPrefix = contentCountPrefix, + medianRawLength = median, + knownContentCount = knownCount, + contentChapterCount = contentCount, + mode = mode, + bookIdentityHash = bookIdentityHash, + tocPrefixHash = tocPrefixHash, + ) + } + } +} + +class ReadingTimeEstimator( + initialState: ReadingTimeState? = null, + private val elapsedRealtime: () -> Long = { System.nanoTime() / 1_000_000L }, +) { + + private var state = initialState?.takeIf { it.version == ReadingTimeState.CURRENT_VERSION } + ?.copy() ?: ReadingTimeState() + private var indexSnapshot = ReadingTimeIndexSnapshot.empty() + private var anchorPosition: ReadingTimePosition? = null + private var anchorElapsedMillis: Long = 0L + private var isActive = false + private var lastEstimateSeconds: Double? = null + private var lastEstimateMode = ReadingTimeEstimateMode.CHAPTER + private var transitionFromSeconds: Double? = null + private var transitionStep = 0 + + fun stateSnapshot(): ReadingTimeState = state.copy() + + fun updateIdentity( + bookIdentityHash: Long, + tocChapterCount: Int, + tocPrefixHash: Long, + sourceLastModified: Long = state.sourceLastModified, + ) { + state.bookIdentityHash = bookIdentityHash + state.tocChapterCount = tocChapterCount + state.tocPrefixHash = tocPrefixHash + state.sourceLastModified = sourceLastModified + } + + fun updateIndex(snapshot: ReadingTimeIndexSnapshot) { + val oldEffectiveMode = effectiveMode(indexSnapshot) + val newEffectiveMode = effectiveMode(snapshot) + indexSnapshot = snapshot + if (oldEffectiveMode != newEffectiveMode && lastEstimateSeconds != null) { + transitionFromSeconds = lastEstimateSeconds + transitionStep = 0 + } + } + + fun resume(position: ReadingTimePosition, nowMillis: Long = elapsedRealtime()) { + isActive = true + anchorPosition = position + anchorElapsedMillis = nowMillis + } + + fun pause() { + isActive = false + anchorPosition = null + } + + fun reanchor(position: ReadingTimePosition, nowMillis: Long = elapsedRealtime()) { + if (!isActive) return + anchorPosition = position + anchorElapsedMillis = nowMillis + } + + fun onForward( + position: ReadingTimePosition, + allowTraining: Boolean = true, + nowMillis: Long = elapsedRealtime(), + ): ReadingTimeAdvanceResult { + val previous = anchorPosition + val elapsed = nowMillis - anchorElapsedMillis + val accepted = isActive && allowTraining && previous != null && + isAdjacentForward(previous, position) && elapsed in MIN_SAMPLE_MILLIS..MAX_SAMPLE_MILLIS + if (accepted) { + updateSpeed(checkNotNull(previous), position, elapsed) + } + if (isActive) { + anchorPosition = position + anchorElapsedMillis = nowMillis + } + return ReadingTimeAdvanceResult( + estimate = estimate(position, accepted), + sampleAccepted = accepted, + ) + } + + fun estimate(position: ReadingTimePosition): ReadingTimeEstimate { + return estimate(position, false) + } + + fun reset(position: ReadingTimePosition? = null, nowMillis: Long = elapsedRealtime()) { + state = ReadingTimeState() + lastEstimateSeconds = null + lastEstimateMode = ReadingTimeEstimateMode.CHAPTER + transitionFromSeconds = null + transitionStep = 0 + if (isActive && position != null) { + anchorPosition = position + anchorElapsedMillis = nowMillis + } else { + anchorPosition = null + } + } + + private fun updateSpeed( + previous: ReadingTimePosition, + current: ReadingTimePosition, + elapsedMillis: Long, + ) { + val elapsedSeconds = elapsedMillis / 1_000.0 + val chapterDelta = current.chapterCoordinate - previous.chapterCoordinate + if (chapterDelta > 0.0) { + state.chapterSecondsPerUnit = updateEwma( + state.chapterSecondsPerUnit, + elapsedSeconds / chapterDelta, + ) + state.sampleCount++ + state.validReadingMillis += elapsedMillis + } + + val previousContent = indexSnapshot.contentCoordinate(previous) + val currentContent = indexSnapshot.contentCoordinate(current) + if (previousContent != null && currentContent != null && + !indexSnapshot.hasUnknownContentBetween(previous.chapterIndex, current.chapterIndex) + ) { + val contentDelta = currentContent - previousContent + if (contentDelta > 0.0) { + state.contentSecondsPerByte = updateEwma( + state.contentSecondsPerByte, + elapsedSeconds / contentDelta, + ) + state.contentSampleCount++ + state.contentValidReadingMillis += elapsedMillis + } + } + } + + private fun estimate( + position: ReadingTimePosition, + advanceTransition: Boolean, + ): ReadingTimeEstimate { + if (indexSnapshot.chapterCount == 0) { + return ReadingTimeEstimate.Unavailable + } + if (isFinished(position)) { + lastEstimateSeconds = 0.0 + val mode = effectiveMode(indexSnapshot) + lastEstimateMode = mode + return ReadingTimeEstimate.Ready(0.0, mode) + } + if (!state.isChapterQualified()) { + return ReadingTimeEstimate.Learning + } + val mode = effectiveMode(indexSnapshot) + if (mode != lastEstimateMode && lastEstimateSeconds != null && transitionFromSeconds == null) { + transitionFromSeconds = lastEstimateSeconds + transitionStep = 0 + } + val targetSeconds = when (mode) { + ReadingTimeEstimateMode.CHAPTER -> { + state.chapterSecondsPerUnit * indexSnapshot.remainingChapterUnits(position) + } + + ReadingTimeEstimateMode.HYBRID_CONTENT, + ReadingTimeEstimateMode.FULL_CONTENT -> { + val remainingBytes = indexSnapshot.remainingContentBytes(position) + if (remainingBytes == null) { + state.chapterSecondsPerUnit * indexSnapshot.remainingChapterUnits(position) + } else { + state.contentSecondsPerByte * remainingBytes + } + } + }.coerceAtLeast(0.0) + + val from = transitionFromSeconds + val displayedSeconds = if (from != null && advanceTransition) { + transitionStep = min(TRANSITION_SAMPLES, transitionStep + 1) + val weight = transitionStep.toDouble() / TRANSITION_SAMPLES + (from * (1.0 - weight) + targetSeconds * weight).also { + if (transitionStep >= TRANSITION_SAMPLES) { + transitionFromSeconds = null + transitionStep = 0 + lastEstimateMode = mode + } + } + } else if (from != null) { + lastEstimateSeconds ?: from + } else { + targetSeconds + } + lastEstimateSeconds = displayedSeconds + if (transitionFromSeconds == null) { + lastEstimateMode = mode + } + return ReadingTimeEstimate.Ready(displayedSeconds, mode) + } + + private fun effectiveMode(snapshot: ReadingTimeIndexSnapshot): ReadingTimeEstimateMode { + return if (snapshot.mode != ReadingTimeEstimateMode.CHAPTER && state.isContentQualified()) { + snapshot.mode + } else { + ReadingTimeEstimateMode.CHAPTER + } + } + + private fun isFinished(position: ReadingTimePosition): Boolean { + val chapterCount = indexSnapshot.chapterCount + return position.chapterIndex >= chapterCount - 1 && position.normalizedProgress >= 1.0 + } + + private fun isAdjacentForward( + previous: ReadingTimePosition, + current: ReadingTimePosition, + ): Boolean { + val chapterDelta = current.chapterIndex - previous.chapterIndex + if (chapterDelta !in 0..1) return false + return current.chapterCoordinate > previous.chapterCoordinate + } + + private fun updateEwma(current: Double, sample: Double): Double { + if (!sample.isFinite() || sample <= 0.0) return current + if (current <= 0.0 || !current.isFinite()) return sample + val clipped = sample.coerceIn(current * OUTLIER_MIN_FACTOR, current * OUTLIER_MAX_FACTOR) + return current * (1.0 - EWMA_ALPHA) + clipped * EWMA_ALPHA + } + + companion object { + const val MIN_SAMPLE_MILLIS = 5_000L + const val MAX_SAMPLE_MILLIS = 120_000L + const val EWMA_ALPHA = 0.2 + const val OUTLIER_MIN_FACTOR = 0.25 + const val OUTLIER_MAX_FACTOR = 4.0 + const val TRANSITION_SAMPLES = 5 + } +} + +object ReadingTimeDuration { + + data class HoursMinutes( + val hours: Long, + val minutes: Int, + ) + + fun accumulatedMinutes(readMillis: Long): Long { + return readMillis.coerceAtLeast(0L) / 60_000L + } + + fun remainingMinutes(remainingSeconds: Double): Long { + if (!remainingSeconds.isFinite() || remainingSeconds <= 0.0) return 0L + return ceil(remainingSeconds / 60.0).toLong().coerceAtLeast(1L) + } + + fun splitMinutes(totalMinutes: Long): HoursMinutes { + val safeMinutes = totalMinutes.coerceAtLeast(0L) + return HoursMinutes( + hours = safeMinutes / 60L, + minutes = (safeMinutes % 60L).toInt(), + ) + } +} diff --git a/app/src/main/java/io/legado/app/model/read/ReadingTimeIndexCodec.kt b/app/src/main/java/io/legado/app/model/read/ReadingTimeIndexCodec.kt new file mode 100644 index 000000000..43661bdc3 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/read/ReadingTimeIndexCodec.kt @@ -0,0 +1,129 @@ +package io.legado.app.model.read + +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.zip.CRC32 + +data class ReadingTimeIndexData( + val bookIdentityHash: Long, + val tocPrefixHash: Long, + val sourceLastModified: Long, + val rawLengths: IntArray, +) + +object ReadingTimeIndexCodec { + + private const val MAGIC = 0x52544931 + private const val VERSION = 1 + private const val FIXED_BYTES_WITH_CRC = 44 + private const val MAX_CHAPTER_COUNT = 1_000_000 + + fun encode(data: ReadingTimeIndexData): ByteArray { + require(data.rawLengths.size <= MAX_CHAPTER_COUNT) + val byteCount = FIXED_BYTES_WITH_CRC + data.rawLengths.size * Int.SIZE_BYTES + val bytes = ByteArray(byteCount) + val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN) + buffer.putInt(MAGIC) + buffer.putInt(VERSION) + buffer.putLong(data.bookIdentityHash) + buffer.putLong(data.tocPrefixHash) + buffer.putLong(data.sourceLastModified) + buffer.putInt(data.rawLengths.size) + buffer.putInt(data.rawLengths.count { it > 0 }) + data.rawLengths.forEach(buffer::putInt) + buffer.putInt(crc32(bytes, bytes.size - Int.SIZE_BYTES)) + return bytes + } + + fun decode(bytes: ByteArray): ReadingTimeIndexData? { + if (bytes.size < FIXED_BYTES_WITH_CRC) return null + val storedCrc = ByteBuffer.wrap(bytes, bytes.size - Int.SIZE_BYTES, Int.SIZE_BYTES) + .order(ByteOrder.BIG_ENDIAN) + .int + if (storedCrc != crc32(bytes, bytes.size - Int.SIZE_BYTES)) return null + val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN) + if (buffer.int != MAGIC || buffer.int != VERSION) return null + val bookIdentityHash = buffer.long + val tocPrefixHash = buffer.long + val sourceLastModified = buffer.long + val chapterCount = buffer.int + val knownCount = buffer.int + if (chapterCount !in 0..MAX_CHAPTER_COUNT) return null + val expectedSize = FIXED_BYTES_WITH_CRC.toLong() + chapterCount.toLong() * Int.SIZE_BYTES + if (expectedSize != bytes.size.toLong()) return null + if (knownCount !in 0..chapterCount) return null + val lengths = IntArray(chapterCount) { buffer.int } + if (lengths.count { it > 0 } != knownCount) return null + return ReadingTimeIndexData( + bookIdentityHash = bookIdentityHash, + tocPrefixHash = tocPrefixHash, + sourceLastModified = sourceLastModified, + rawLengths = lengths, + ) + } + + fun read(file: File): ReadingTimeIndexData? { + return kotlin.runCatching { + if (!file.isFile) return null + decode(file.readBytes()) + }.getOrNull() + } + + fun write(file: File, data: ReadingTimeIndexData): Boolean { + return kotlin.runCatching { + val parent = file.parentFile ?: return false + if (!parent.exists() && !parent.mkdirs()) return false + val tempFile = File(parent, "${file.name}.tmp") + val backupFile = File(parent, "${file.name}.bak") + if (tempFile.exists() && !tempFile.delete()) return false + tempFile.writeBytes(encode(data)) + if (read(tempFile) == null) { + tempFile.delete() + return false + } + if (backupFile.exists() && !backupFile.delete()) return false + if (file.exists() && !file.renameTo(backupFile)) return false + if (!tempFile.renameTo(file)) { + if (backupFile.exists()) backupFile.renameTo(file) + tempFile.delete() + return false + } + backupFile.delete() + true + }.getOrDefault(false) + } + + private fun crc32(bytes: ByteArray, length: Int): Int { + val crc = CRC32() + crc.update(bytes, 0, length) + return crc.value.toInt() + } +} + +object ReadingTimeIdentity { + + private const val FNV_OFFSET_BASIS = -3750763034362895579L + private const val FNV_PRIME = 1099511628211L + + fun hash(parts: Iterable): Long { + var result = FNV_OFFSET_BASIS + parts.forEach { part -> + part.forEach { char -> + result = (result xor char.code.toLong()) * FNV_PRIME + } + result = (result xor 0xffL) * FNV_PRIME + } + return result + } + + fun extend(seed: Long, part: String): Long { + var result = seed + part.forEach { char -> + result = (result xor char.code.toLong()) * FNV_PRIME + } + return (result xor 0xffL) * FNV_PRIME + } + + fun initialHash(): Long = FNV_OFFSET_BASIS +} diff --git a/app/src/main/java/io/legado/app/model/read/ReadingTimeIndexReconciler.kt b/app/src/main/java/io/legado/app/model/read/ReadingTimeIndexReconciler.kt new file mode 100644 index 000000000..86eedca09 --- /dev/null +++ b/app/src/main/java/io/legado/app/model/read/ReadingTimeIndexReconciler.kt @@ -0,0 +1,69 @@ +package io.legado.app.model.read + +data class ReadingTimeTocEntry( + val identity: String, + val directRawLength: Int = ReadingTimeIndexSnapshot.UNKNOWN_LENGTH, +) + +data class ReadingTimeIndexReconcileResult( + val rawLengths: IntArray, + val tocPrefixHash: Long, + val resetSpeedModel: Boolean, +) + +object ReadingTimeIndexReconciler { + + fun shouldResetSpeedState( + state: ReadingTimeState?, + bookIdentityHash: Long, + sourceLastModified: Long, + entries: List, + ): Boolean { + state ?: return false + if (state.bookIdentityHash == 0L) return false + return state.bookIdentityHash != bookIdentityHash || + state.sourceLastModified != sourceLastModified || + state.tocChapterCount > entries.size || + tocHash(entries, state.tocChapterCount) != state.tocPrefixHash + } + + fun reconcile( + stored: ReadingTimeIndexData?, + bookIdentityHash: Long, + sourceLastModified: Long, + entries: List, + ): ReadingTimeIndexReconcileResult { + val fullHash = tocHash(entries) + val directLengths = IntArray(entries.size) { entries[it].directRawLength } + if (stored == null) { + return ReadingTimeIndexReconcileResult(directLengths, fullHash, false) + } + if (stored.bookIdentityHash != bookIdentityHash || + stored.sourceLastModified != sourceLastModified || + stored.rawLengths.size > entries.size + ) { + return ReadingTimeIndexReconcileResult(directLengths, fullHash, true) + } + val storedPrefixHash = tocHash(entries, stored.rawLengths.size) + if (storedPrefixHash != stored.tocPrefixHash) { + return ReadingTimeIndexReconcileResult(directLengths, fullHash, true) + } + val merged = directLengths.copyOf() + stored.rawLengths.copyInto(merged, endIndex = stored.rawLengths.size) + entries.forEachIndexed { index, entry -> + if (entry.directRawLength >= 0) { + merged[index] = entry.directRawLength + } + } + return ReadingTimeIndexReconcileResult(merged, fullHash, false) + } + + fun tocHash(entries: List, count: Int = entries.size): Long { + var result = ReadingTimeIdentity.initialHash() + val safeCount = count.coerceIn(0, entries.size) + for (index in 0 until safeCount) { + result = ReadingTimeIdentity.extend(result, entries[index].identity) + } + return result + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt index 85e9d0456..ced72af61 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt @@ -333,6 +333,7 @@ class ReadBookActivity : BaseReadBookActivity(), override fun onResume() { super.onResume() ReadBook.readStartTime = System.currentTimeMillis() + ReadBook.onReadingTimeResumed() if (bookChanged) { bookChanged = false ReadBook.callBack = this @@ -363,6 +364,8 @@ class ReadBookActivity : BaseReadBookActivity(), super.onPause() autoPageStop() backupJob?.cancel() + ReadBook.onReadingTimePaused() + ReadBook.upReadTime() ReadBook.saveRead() ReadBook.cancelPreDownloadTask() unregisterReceiver(timeBatteryReceiver) @@ -1494,10 +1497,12 @@ class ReadBookActivity : BaseReadBookActivity(), override fun onMenuShow() { binding.readView.autoPager.pause() + ReadBook.onReadingTimeMenuVisibilityChanged(true) } override fun onMenuHide() { binding.readView.autoPager.resume() + ReadBook.onReadingTimeMenuVisibilityChanged(false) } override fun onLayoutPageCompleted(index: Int, page: TextPage) { @@ -1628,7 +1633,10 @@ class ReadBookActivity : BaseReadBookActivity(), } override fun observeLiveBus() = binding.run { - observeEvent(EventBus.TIME_CHANGED) { readView.upTime() } + observeEvent(EventBus.TIME_CHANGED) { + ReadBook.refreshReadingTimeDisplay() + readView.upTime() + } observeEvent(EventBus.BATTERY_CHANGED) { readView.upBattery(it) } observeEvent(EventBus.MEDIA_BUTTON) { if (it) { @@ -1638,6 +1646,9 @@ class ReadBookActivity : BaseReadBookActivity(), } } observeEvent>(EventBus.UP_CONFIG) { + if (it.any { value -> value == 5 || value == 8 || value == 10 }) { + ReadBook.onReadingTimeLayoutChanged() + } it.forEach { value -> when (value) { 0 -> upSystemUiVisibility() @@ -1656,6 +1667,7 @@ class ReadBookActivity : BaseReadBookActivity(), } } observeEvent(EventBus.ALOUD_STATE) { + ReadBook.onReadingTimeAloudStateChanged() if (it == Status.STOP || it == Status.PAUSE) { ReadBook.curTextChapter?.let { textChapter -> val page = textChapter.getPageByReadPos(ReadBook.durChapterPos) diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/TipConfigDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/TipConfigDialog.kt index 398428747..7063208f8 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/config/TipConfigDialog.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/config/TipConfigDialog.kt @@ -11,13 +11,16 @@ import io.legado.app.constant.EventBus import io.legado.app.databinding.DialogTipConfigBinding import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.config.ReadTipConfig +import io.legado.app.lib.dialogs.alert import io.legado.app.lib.dialogs.selector +import io.legado.app.model.ReadBook import io.legado.app.utils.checkByIndex import io.legado.app.utils.getIndexById import io.legado.app.utils.hexString import io.legado.app.utils.observeEvent import io.legado.app.utils.postEvent import io.legado.app.utils.setLayout +import io.legado.app.utils.toastOnUi import io.legado.app.utils.viewbindingdelegate.viewBinding @@ -76,6 +79,7 @@ class TipConfigDialog : BaseDialogFragment(R.layout.dialog_tip_config) { } upTvTipColor() upTvTipDividerColor() + binding.llResetReadingTimeEstimation.isEnabled = ReadBook.book != null } private fun upTvTipColor() { @@ -136,6 +140,7 @@ class TipConfigDialog : BaseDialogFragment(R.layout.dialog_tip_config) { clearRepeat(tipValue) ReadTipConfig.tipHeaderLeft = tipValue tvHeaderLeft.text = ReadTipConfig.tipNames[i] + ReadBook.onReadingTimeTipConfigChanged() postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6)) } } @@ -145,6 +150,7 @@ class TipConfigDialog : BaseDialogFragment(R.layout.dialog_tip_config) { clearRepeat(tipValue) ReadTipConfig.tipHeaderMiddle = tipValue tvHeaderMiddle.text = ReadTipConfig.tipNames[i] + ReadBook.onReadingTimeTipConfigChanged() postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6)) } } @@ -154,6 +160,7 @@ class TipConfigDialog : BaseDialogFragment(R.layout.dialog_tip_config) { clearRepeat(tipValue) ReadTipConfig.tipHeaderRight = tipValue tvHeaderRight.text = ReadTipConfig.tipNames[i] + ReadBook.onReadingTimeTipConfigChanged() postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6)) } } @@ -163,6 +170,7 @@ class TipConfigDialog : BaseDialogFragment(R.layout.dialog_tip_config) { clearRepeat(tipValue) ReadTipConfig.tipFooterLeft = tipValue tvFooterLeft.text = ReadTipConfig.tipNames[i] + ReadBook.onReadingTimeTipConfigChanged() postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6)) } } @@ -172,6 +180,7 @@ class TipConfigDialog : BaseDialogFragment(R.layout.dialog_tip_config) { clearRepeat(tipValue) ReadTipConfig.tipFooterMiddle = tipValue tvFooterMiddle.text = ReadTipConfig.tipNames[i] + ReadBook.onReadingTimeTipConfigChanged() postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6)) } } @@ -181,6 +190,7 @@ class TipConfigDialog : BaseDialogFragment(R.layout.dialog_tip_config) { clearRepeat(tipValue) ReadTipConfig.tipFooterRight = tipValue tvFooterRight.text = ReadTipConfig.tipNames[i] + ReadBook.onReadingTimeTipConfigChanged() postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6)) } } @@ -218,6 +228,20 @@ class TipConfigDialog : BaseDialogFragment(R.layout.dialog_tip_config) { } } } + llResetReadingTimeEstimation.setOnClickListener { + alert( + titleResource = R.string.reset_reading_time_estimation, + messageResource = R.string.reset_reading_time_estimation_message, + ) { + noButton() + yesButton { + if (ReadBook.resetReadingTimeEstimation()) { + postEvent(EventBus.UP_CONFIG, arrayListOf(6)) + context?.toastOnUi(R.string.reading_time_estimation_reset) + } + } + } + } } private fun clearRepeat(repeat: Int) = ReadTipConfig.apply { @@ -249,4 +273,4 @@ class TipConfigDialog : BaseDialogFragment(R.layout.dialog_tip_config) { } } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt b/app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt index e343afdff..5a8812b48 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt @@ -54,6 +54,9 @@ class PageView(context: Context) : FrameLayout(context) { private var tvBookName: BatteryView? = null private var tvTimeBattery: BatteryView? = null private var tvTimeBatteryP: BatteryView? = null + private var tvReadTime: BatteryView? = null + private var tvRemainingReadTime: BatteryView? = null + private var tvReadAndRemainingTime: BatteryView? = null private var isMainView = false var isScroll = false @@ -243,6 +246,24 @@ class PageView(context: Context) : FrameLayout(context) { typeface = ChapterProvider.typeface textSize = 12f } + tvReadTime = getTipView(ReadTipConfig.readTime)?.apply { + tag = ReadTipConfig.readTime + isBattery = false + typeface = ChapterProvider.typeface + textSize = 12f + } + tvRemainingReadTime = getTipView(ReadTipConfig.remainingReadTime)?.apply { + tag = ReadTipConfig.remainingReadTime + isBattery = false + typeface = ChapterProvider.typeface + textSize = 12f + } + tvReadAndRemainingTime = getTipView(ReadTipConfig.readAndRemainingTime)?.apply { + tag = ReadTipConfig.readAndRemainingTime + isBattery = false + typeface = ChapterProvider.typeface + textSize = 12f + } } /** @@ -288,6 +309,7 @@ class PageView(context: Context) : FrameLayout(context) { fun upTime() { tvTime?.text = timeFormat.format(Date(System.currentTimeMillis())) upTimeBattery() + upReadingTime() } /** @@ -311,6 +333,13 @@ class PageView(context: Context) : FrameLayout(context) { tvTimeBatteryP?.text = "$time $battery%" } + private fun upReadingTime() { + val display = ReadBook.readingTimeDisplay + tvReadTime?.setTextIfNotEqual(display.accumulated) + tvRemainingReadTime?.setTextIfNotEqual(display.remaining) + tvReadAndRemainingTime?.setTextIfNotEqual(display.combined) + } + /** * 设置内容 */ @@ -351,6 +380,7 @@ class PageView(context: Context) : FrameLayout(context) { */ @SuppressLint("SetTextI18n") fun setProgress(textPage: TextPage) = textPage.apply { + upReadingTime() tvBookName?.setTextIfNotEqual(ReadBook.book?.name) tvTitle?.setTextIfNotEqual(textPage.title) val readProgress = readProgress @@ -500,4 +530,4 @@ class PageView(context: Context) : FrameLayout(context) { val selectedText: String get() = binding.contentTextView.getSelectedText() val selectStartPos get() = binding.contentTextView.selectStart -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/provider/TextPageFactory.kt b/app/src/main/java/io/legado/app/ui/book/read/page/provider/TextPageFactory.kt index e261bcc02..556bd14ba 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/page/provider/TextPageFactory.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/page/provider/TextPageFactory.kt @@ -24,17 +24,17 @@ class TextPageFactory(dataSource: DataSource) : PageFactory(dataSource } override fun moveToFirst() { - ReadBook.setPageIndex(0) + ReadBook.setPageIndex(0, false) } override fun moveToLast() = with(dataSource) { currentChapter?.let { if (it.pageSize == 0) { - ReadBook.setPageIndex(0) + ReadBook.setPageIndex(0, false) } else { - ReadBook.setPageIndex(it.pageSize.minus(1)) + ReadBook.setPageIndex(it.pageSize.minus(1), false) } - } ?: ReadBook.setPageIndex(0) + } ?: ReadBook.setPageIndex(0, false) } override fun moveToNext(upContent: Boolean): Boolean = with(dataSource) { @@ -44,12 +44,12 @@ class TextPageFactory(dataSource: DataSource) : PageFactory(dataSource if ((currentChapter == null || isScroll) && nextChapter == null) { return@with false } - ReadBook.moveToNextChapter(upContent, false) + ReadBook.moveToNextChapter(upContent, false, true) } else { if (pageIndex < 0 || currentChapter?.isLastIndexCurrent(pageIndex) == true) { return@with false } - ReadBook.setPageIndex(pageIndex.plus(1)) + ReadBook.setPageIndex(pageIndex.plus(1), true) } if (upContent) upContent(resetPageOffset = false) true @@ -71,7 +71,7 @@ class TextPageFactory(dataSource: DataSource) : PageFactory(dataSource if (currentChapter == null) { return@with false } - ReadBook.setPageIndex(pageIndex.minus(1)) + ReadBook.setPageIndex(pageIndex.minus(1), false) } if (upContent) upContent(resetPageOffset = false) true diff --git a/app/src/main/res/layout/dialog_tip_config.xml b/app/src/main/res/layout/dialog_tip_config.xml index 2764e47a3..2d0918aeb 100644 --- a/app/src/main/res/layout/dialog_tip_config.xml +++ b/app/src/main/res/layout/dialog_tip_config.xml @@ -348,6 +348,14 @@ + + - \ No newline at end of file + diff --git a/app/src/main/res/values-es-rES/arrays.xml b/app/src/main/res/values-es-rES/arrays.xml index c16295573..ee1ada73e 100644 --- a/app/src/main/res/values-es-rES/arrays.xml +++ b/app/src/main/res/values-es-rES/arrays.xml @@ -97,6 +97,9 @@ Páginas y avance Tiempo y Batería Tiempo y Batería% + Tiempo de lectura acumulado + Tiempo de lectura restante estimado + Tiempo leído y restante diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 51049411d..8448aa135 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -1226,4 +1226,14 @@ 自动检查新备份 打开软件时检查是否有新备份,有新备份时提示是否更新 系统图片选择器 + Leído %1$d min + Leído %1$d h %2$d min + Quedan %1$d min + Quedan %1$d h %2$d min + Aprendiendo + + %1$s · %2$s + Restablecer estimación de lectura + ¿Restablecer la velocidad aprendida para este libro? Se conservarán el historial y el contenido almacenado. + Estimación de lectura restablecida diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 6f7672b66..58969ad93 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -1229,4 +1229,14 @@ 自动检查新备份 打开软件时检查是否有新备份,有新备份时提示是否更新 系统图片选择器 + 読書済み %1$d分 + 読書済み %1$d時間%2$d分 + 残り %1$d分 + 残り %1$d時間%2$d分 + 学習中 + + %1$s · %2$s + この本の読書時間推定をリセット + この本で学習した読書速度をリセットしますか?読書履歴とキャッシュ済み本文は保持されます。 + 読書時間推定をリセットしました diff --git a/app/src/main/res/values-pt-rBR/arrays.xml b/app/src/main/res/values-pt-rBR/arrays.xml index d8d9a17b6..9d3dd3674 100644 --- a/app/src/main/res/values-pt-rBR/arrays.xml +++ b/app/src/main/res/values-pt-rBR/arrays.xml @@ -97,6 +97,9 @@ Páginas e progresso Tempo e Bateria Tempo e Bateria% + Tempo de leitura acumulado + Tempo de leitura restante estimado + Tempo lido e restante diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index b7d92b8b4..d42cc69c1 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1229,4 +1229,14 @@ 自动检查新备份 打开软件时检查是否有新备份,有新备份时提示是否更新 系统图片选择器 + Lido %1$d min + Lido %1$d h %2$d min + Restam %1$d min + Restam %1$d h %2$d min + Aprendendo + + %1$s · %2$s + Redefinir estimativa de leitura + Redefinir a velocidade aprendida para este livro? O histórico e o conteúdo em cache serão mantidos. + Estimativa de leitura redefinida diff --git a/app/src/main/res/values-vi/arrays.xml b/app/src/main/res/values-vi/arrays.xml index 59fb6d098..0c62367e6 100644 --- a/app/src/main/res/values-vi/arrays.xml +++ b/app/src/main/res/values-vi/arrays.xml @@ -75,6 +75,9 @@ Số trang và Tiến độ Thời gian và Pin Thời gian và Pin % + Thời lượng đã đọc + Thời gian đọc còn lại dự kiến + Đã đọc và thời gian còn lại diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index ed4d47a1f..ef3fcb94a 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -1220,4 +1220,14 @@ 自动检查新备份 打开软件时检查是否有新备份,有新备份时提示是否更新 系统图片选择器 + Đã đọc %1$d phút + Đã đọc %1$d giờ %2$d phút + Còn %1$d phút + Còn %1$d giờ %2$d phút + Đang học + + %1$s · %2$s + Đặt lại ước tính thời gian đọc + Đặt lại tốc độ đọc đã học cho sách này? Lịch sử đọc và nội dung đã lưu vẫn được giữ. + Đã đặt lại ước tính thời gian đọc diff --git a/app/src/main/res/values-zh-rHK/arrays.xml b/app/src/main/res/values-zh-rHK/arrays.xml index 93829b1e7..893eacb73 100644 --- a/app/src/main/res/values-zh-rHK/arrays.xml +++ b/app/src/main/res/values-zh-rHK/arrays.xml @@ -60,6 +60,9 @@ 頁數同埋進度 時間同埋電量 時間同埋電量% + 累計閱讀時長 + 預計剩餘閱讀時間 + 累計同剩餘閱讀時間 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index b91887036..993619a75 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -1224,4 +1224,14 @@ 自动检查新备份 打开软件时检查是否有新备份,有新备份时提示是否更新 系统图片选择器 + 已讀%1$d分 + 已讀%1$d時%2$d分 + 剩餘%1$d分 + 剩餘%1$d時%2$d分 + 學習中 + + %1$s·%2$s + 重設本書閱讀速度估算 + 確定重設本書已學習嘅閱讀速度嗎?累計閱讀記錄同正文快取會保留。 + 已重設本書閱讀速度估算 diff --git a/app/src/main/res/values-zh-rTW/arrays.xml b/app/src/main/res/values-zh-rTW/arrays.xml index e9e5a1bf7..35cb9e6cd 100644 --- a/app/src/main/res/values-zh-rTW/arrays.xml +++ b/app/src/main/res/values-zh-rTW/arrays.xml @@ -95,6 +95,9 @@ 頁數及進度 時間及電量 時間及電量% + 累計閱讀時長 + 預計剩餘閱讀時間 + 累計與剩餘閱讀時間 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 4174b2118..d5dc9aac2 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1225,4 +1225,14 @@ 自动检查新备份 打开软件时检查是否有新备份,有新备份时提示是否更新 系统图片选择器 + 已讀%1$d分 + 已讀%1$d時%2$d分 + 剩餘%1$d分 + 剩餘%1$d時%2$d分 + 學習中 + + %1$s·%2$s + 重設本書閱讀速度估算 + 確定重設本書已學習的閱讀速度嗎?累計閱讀記錄和正文快取將會保留。 + 已重設本書閱讀速度估算 diff --git a/app/src/main/res/values-zh/arrays.xml b/app/src/main/res/values-zh/arrays.xml index 8adc1c883..8566efc4a 100644 --- a/app/src/main/res/values-zh/arrays.xml +++ b/app/src/main/res/values-zh/arrays.xml @@ -82,6 +82,9 @@ 页数及进度 时间及电量 时间及电量% + 累计阅读时长 + 预计剩余阅读时间 + 累计与剩余阅读时间 @@ -114,4 +117,4 @@ 自定义 - \ No newline at end of file + diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index b58247bc5..c56683e44 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1231,4 +1231,14 @@ 自动检查新备份 打开软件时检查是否有新备份,有新备份时提示是否更新 系统图片选择器 + 已读%1$d分 + 已读%1$d时%2$d分 + 剩余%1$d分 + 剩余%1$d时%2$d分 + 学习中 + + %1$s·%2$s + 重置本书阅读速度估算 + 确定重置本书已经学习的阅读速度吗?累计阅读记录和正文缓存将会保留。 + 已重置本书阅读速度估算 diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 67350daa6..d94d2e702 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -124,6 +124,9 @@ Pages and progress Time and Battery Time and Battery% + Reading duration + Estimated time left + Reading and time left diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f3e9e1a40..6796da976 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1233,4 +1233,14 @@ 自动检查新备份 打开软件时检查是否有新备份,有新备份时提示是否更新 系统图片选择器 + %1$d min read + %1$d h %2$d min read + %1$d min left + %1$d h %2$d min left + Learning + + %1$s · %2$s + Reset reading time estimate + Reset the learned reading speed for this book? Reading history and cached content will be kept. + Reading time estimate reset diff --git a/app/src/test/java/io/legado/app/help/config/ReadTipConfigResourceTest.kt b/app/src/test/java/io/legado/app/help/config/ReadTipConfigResourceTest.kt new file mode 100644 index 000000000..69ee806e4 --- /dev/null +++ b/app/src/test/java/io/legado/app/help/config/ReadTipConfigResourceTest.kt @@ -0,0 +1,46 @@ +package io.legado.app.help.config + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.w3c.dom.Element +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory + +class ReadTipConfigResourceTest { + + @Test + fun `tip ids are unique and every read tip array has the same size`() { + val expectedSize = ReadTipConfig.tipValues.size + assertEquals(expectedSize, ReadTipConfig.tipValues.toSet().size) + + val resourceRoot = requireNotNull( + sequenceOf(File("src/main/res"), File("app/src/main/res")) + .firstOrNull(File::isDirectory) + ) + val overriddenArrays = resourceRoot.listFiles().orEmpty() + .filter { it.isDirectory && it.name.startsWith("values") } + .map { File(it, "arrays.xml") } + .filter(File::isFile) + .mapNotNull { file -> + val document = DocumentBuilderFactory.newInstance() + .newDocumentBuilder() + .parse(file) + val arrays = document.getElementsByTagName("string-array") + (0 until arrays.length) + .map { arrays.item(it) as Element } + .firstOrNull { it.getAttribute("name") == "read_tip" } + ?.let { array -> + (file.parentFile?.name ?: "values") to + array.getElementsByTagName("item").length + } + } + + assertFalse(overriddenArrays.isEmpty()) + overriddenArrays.forEach { (directory, actualSize) -> + assertEquals("$directory/read_tip", expectedSize, actualSize) + } + assertTrue(overriddenArrays.any { it.first == "values" }) + } +} diff --git a/app/src/test/java/io/legado/app/model/read/ReadingTimeEstimatorTest.kt b/app/src/test/java/io/legado/app/model/read/ReadingTimeEstimatorTest.kt new file mode 100644 index 000000000..60e054213 --- /dev/null +++ b/app/src/test/java/io/legado/app/model/read/ReadingTimeEstimatorTest.kt @@ -0,0 +1,248 @@ +package io.legado.app.model.read + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReadingTimeEstimatorTest { + + @Test + fun `five samples and sixty seconds unlock estimate`() { + var now = 0L + val estimator = ReadingTimeEstimator(elapsedRealtime = { now }) + estimator.updateIndex(ReadingTimeIndexSnapshot.empty(10)) + estimator.resume(ReadingTimePosition(0, 0.0)) + + repeat(4) { index -> + now += 12_000L + val result = estimator.onForward(ReadingTimePosition(0, (index + 1) / 10.0)) + assertTrue(result.sampleAccepted) + assertTrue(result.estimate is ReadingTimeEstimate.Learning) + } + + now += 12_000L + val result = estimator.onForward(ReadingTimePosition(0, 0.5)) + assertTrue(result.sampleAccepted) + assertTrue(result.estimate is ReadingTimeEstimate.Ready) + assertEquals(5, estimator.stateSnapshot().sampleCount) + assertEquals(60_000L, estimator.stateSnapshot().validReadingMillis) + } + + @Test + fun `short long backward and paused moves do not train`() { + var now = 0L + val estimator = ReadingTimeEstimator(elapsedRealtime = { now }) + estimator.updateIndex(ReadingTimeIndexSnapshot.empty(10)) + estimator.resume(ReadingTimePosition(0, 0.0)) + + now += 4_999L + assertFalse(estimator.onForward(ReadingTimePosition(0, 0.1)).sampleAccepted) + now += 120_001L + assertFalse(estimator.onForward(ReadingTimePosition(0, 0.2)).sampleAccepted) + now += 10_000L + assertFalse(estimator.onForward(ReadingTimePosition(0, 0.1)).sampleAccepted) + estimator.pause() + now += 10_000L + assertFalse(estimator.onForward(ReadingTimePosition(0, 0.2)).sampleAccepted) + assertEquals(0, estimator.stateSnapshot().sampleCount) + } + + @Test + fun `chapter mode estimates by chapter coordinate`() { + val state = qualifiedState(chapterSecondsPerUnit = 600.0) + val estimator = ReadingTimeEstimator(state) + estimator.updateIndex(ReadingTimeIndexSnapshot.empty(10)) + + val estimate = estimator.estimate(ReadingTimePosition(2, 0.5)) as ReadingTimeEstimate.Ready + + assertEquals(ReadingTimeEstimateMode.CHAPTER, estimate.mode) + assertEquals(4_500.0, estimate.remainingSeconds, 0.001) + } + + @Test + fun `full content mode uses actual remaining bytes`() { + val state = qualifiedState( + chapterSecondsPerUnit = 600.0, + contentSecondsPerByte = 0.1, + ) + val estimator = ReadingTimeEstimator(state) + estimator.updateIndex(ReadingTimeIndexSnapshot.create(intArrayOf(100, 200, 300))) + + val estimate = estimator.estimate(ReadingTimePosition(1, 0.5)) as ReadingTimeEstimate.Ready + + assertEquals(ReadingTimeEstimateMode.FULL_CONTENT, estimate.mode) + assertEquals(40.0, estimate.remainingSeconds, 0.001) + } + + @Test + fun `hybrid mode fills unknown chapters with median`() { + val lengths = IntArray(25) { index -> + if (index < 20) 100 else ReadingTimeIndexSnapshot.UNKNOWN_LENGTH + } + val state = qualifiedState( + chapterSecondsPerUnit = 600.0, + contentSecondsPerByte = 0.1, + ) + val estimator = ReadingTimeEstimator(state) + estimator.updateIndex(ReadingTimeIndexSnapshot.create(lengths)) + + val estimate = estimator.estimate(ReadingTimePosition(20, 0.0)) as ReadingTimeEstimate.Ready + + assertEquals(ReadingTimeEstimateMode.HYBRID_CONTENT, estimate.mode) + assertEquals(50.0, estimate.remainingSeconds, 0.001) + } + + @Test + fun `content mode waits for its own evidence`() { + val state = qualifiedState(chapterSecondsPerUnit = 600.0).copy( + contentSecondsPerByte = 0.1, + contentSampleCount = 4, + contentValidReadingMillis = 59_000L, + ) + val estimator = ReadingTimeEstimator(state) + estimator.updateIndex(ReadingTimeIndexSnapshot.create(intArrayOf(100, 200, 300))) + + val estimate = estimator.estimate(ReadingTimePosition(1, 0.5)) as ReadingTimeEstimate.Ready + + assertEquals(ReadingTimeEstimateMode.CHAPTER, estimate.mode) + } + + @Test + fun `finished book reports zero`() { + val estimator = ReadingTimeEstimator(qualifiedState()) + estimator.updateIndex(ReadingTimeIndexSnapshot.empty(2)) + + val estimate = estimator.estimate(ReadingTimePosition(1, 1.0)) as ReadingTimeEstimate.Ready + + assertEquals(0.0, estimate.remainingSeconds, 0.0) + } + + @Test + fun `duration rounds remaining time upward`() { + assertEquals(0L, ReadingTimeDuration.remainingMinutes(0.0)) + assertEquals(1L, ReadingTimeDuration.remainingMinutes(0.1)) + assertEquals(1L, ReadingTimeDuration.remainingMinutes(60.0)) + assertEquals(2L, ReadingTimeDuration.remainingMinutes(60.1)) + assertEquals(80L, ReadingTimeDuration.accumulatedMinutes(4_800_999L)) + assertEquals( + ReadingTimeDuration.HoursMinutes(1L, 20), + ReadingTimeDuration.splitMinutes(80L), + ) + assertEquals( + ReadingTimeDuration.HoursMinutes(55L, 24), + ReadingTimeDuration.splitMinutes(3_324L), + ) + } + + @Test + fun `ewma clips a single extreme sample`() { + var now = 0L + val estimator = ReadingTimeEstimator(elapsedRealtime = { now }) + estimator.updateIndex(ReadingTimeIndexSnapshot.empty(10)) + estimator.resume(ReadingTimePosition(0, 0.0)) + now += 10_000L + estimator.onForward(ReadingTimePosition(0, 0.1)) + val firstRate = estimator.stateSnapshot().chapterSecondsPerUnit + + now += 120_000L + estimator.onForward(ReadingTimePosition(0, 0.2)) + val clippedRate = estimator.stateSnapshot().chapterSecondsPerUnit + + assertEquals(100.0, firstRate, 0.001) + assertEquals(160.0, clippedRate, 0.001) + } + + @Test + fun `adjacent chapter advance trains while chapter jump does not`() { + var now = 0L + val estimator = ReadingTimeEstimator(elapsedRealtime = { now }) + estimator.updateIndex(ReadingTimeIndexSnapshot.empty(10)) + estimator.resume(ReadingTimePosition(0, 0.9)) + + now += 12_000L + assertTrue(estimator.onForward(ReadingTimePosition(1, 0.1)).sampleAccepted) + now += 12_000L + assertFalse(estimator.onForward(ReadingTimePosition(3, 0.1)).sampleAccepted) + + assertEquals(1, estimator.stateSnapshot().sampleCount) + assertEquals(12_000L, estimator.stateSnapshot().validReadingMillis) + } + + @Test + fun `reanchor discards time spent before jump or obstruction`() { + var now = 0L + val estimator = ReadingTimeEstimator(elapsedRealtime = { now }) + estimator.updateIndex(ReadingTimeIndexSnapshot.empty(10)) + estimator.resume(ReadingTimePosition(0, 0.0)) + + now += 30_000L + estimator.reanchor(ReadingTimePosition(0, 0.5)) + now += 12_000L + val result = estimator.onForward(ReadingTimePosition(0, 0.6)) + + assertTrue(result.sampleAccepted) + assertEquals(12_000L, estimator.stateSnapshot().validReadingMillis) + assertEquals(120.0, estimator.stateSnapshot().chapterSecondsPerUnit, 0.001) + } + + @Test + fun `reset clears learned speed but retains the current index`() { + val estimator = ReadingTimeEstimator( + qualifiedState(chapterSecondsPerUnit = 600.0, contentSecondsPerByte = 0.1) + ) + estimator.updateIndex(ReadingTimeIndexSnapshot.create(intArrayOf(100, 200, 300))) + assertTrue(estimator.estimate(ReadingTimePosition(0, 0.5)) is ReadingTimeEstimate.Ready) + + estimator.reset(ReadingTimePosition(0, 0.5)) + + assertEquals(ReadingTimeState(), estimator.stateSnapshot()) + assertTrue(estimator.estimate(ReadingTimePosition(0, 0.5)) is ReadingTimeEstimate.Learning) + } + + @Test + fun `content mode transition completes after five accepted samples`() { + var now = 0L + val estimator = ReadingTimeEstimator( + qualifiedState(chapterSecondsPerUnit = 600.0, contentSecondsPerByte = 0.1), + elapsedRealtime = { now }, + ) + estimator.updateIndex(ReadingTimeIndexSnapshot.empty(10)) + estimator.resume(ReadingTimePosition(0, 0.0)) + val chapterEstimate = estimator.estimate(ReadingTimePosition(0, 0.0)) + as ReadingTimeEstimate.Ready + estimator.updateIndex(ReadingTimeIndexSnapshot.create(IntArray(10) { 100 })) + + val estimates = (1..5).map { step -> + now += 12_000L + estimator.onForward(ReadingTimePosition(0, step / 10.0)).estimate + as ReadingTimeEstimate.Ready + } + + assertEquals(ReadingTimeEstimateMode.CHAPTER, chapterEstimate.mode) + assertTrue(estimates.all { it.mode == ReadingTimeEstimateMode.FULL_CONTENT }) + assertTrue(estimates.zipWithNext().all { (before, after) -> + before.remainingSeconds > after.remainingSeconds + }) + assertEquals( + estimates.last().remainingSeconds, + (estimator.estimate(ReadingTimePosition(0, 0.5)) as ReadingTimeEstimate.Ready) + .remainingSeconds, + 0.001, + ) + } + + private fun qualifiedState( + chapterSecondsPerUnit: Double = 600.0, + contentSecondsPerByte: Double = 0.0, + ): ReadingTimeState { + return ReadingTimeState( + chapterSecondsPerUnit = chapterSecondsPerUnit, + contentSecondsPerByte = contentSecondsPerByte, + sampleCount = 5, + contentSampleCount = if (contentSecondsPerByte > 0.0) 5 else 0, + validReadingMillis = 60_000L, + contentValidReadingMillis = if (contentSecondsPerByte > 0.0) 60_000L else 0L, + ) + } +} diff --git a/app/src/test/java/io/legado/app/model/read/ReadingTimeIndexCodecTest.kt b/app/src/test/java/io/legado/app/model/read/ReadingTimeIndexCodecTest.kt new file mode 100644 index 000000000..6583c32f2 --- /dev/null +++ b/app/src/test/java/io/legado/app/model/read/ReadingTimeIndexCodecTest.kt @@ -0,0 +1,210 @@ +package io.legado.app.model.read + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import kotlin.io.path.createTempDirectory + +class ReadingTimeIndexCodecTest { + + @Test + fun `codec round trip preserves header and lengths`() { + val data = ReadingTimeIndexData( + bookIdentityHash = 11L, + tocPrefixHash = 22L, + sourceLastModified = 33L, + rawLengths = intArrayOf(100, 0, -1, 250), + ) + + val decoded = ReadingTimeIndexCodec.decode(ReadingTimeIndexCodec.encode(data)) + + requireNotNull(decoded) + assertEquals(data.bookIdentityHash, decoded.bookIdentityHash) + assertEquals(data.tocPrefixHash, decoded.tocPrefixHash) + assertEquals(data.sourceLastModified, decoded.sourceLastModified) + assertTrue(data.rawLengths.contentEquals(decoded.rawLengths)) + } + + @Test + fun `codec rejects truncation corruption and unsupported version`() { + val bytes = ReadingTimeIndexCodec.encode( + ReadingTimeIndexData(1L, 2L, 3L, intArrayOf(100, -1, 200)) + ) + + assertNull(ReadingTimeIndexCodec.decode(bytes.copyOf(bytes.size - 1))) + assertNull(ReadingTimeIndexCodec.decode(bytes.copyOf().also { it[20] = (it[20] + 1).toByte() })) + assertNull(ReadingTimeIndexCodec.decode(bytes.copyOf().also { it[7] = 2 })) + } + + @Test + fun `file write replaces old data and ignores broken file`() { + val directory = createTempDirectory("reading-time-index-").toFile() + val file = File(directory, "reading_time_index.bin") + try { + val first = ReadingTimeIndexData(1L, 2L, 3L, intArrayOf(100)) + val second = ReadingTimeIndexData(4L, 5L, 6L, intArrayOf(200, 300)) + + assertTrue(ReadingTimeIndexCodec.write(file, first)) + assertTrue(ReadingTimeIndexCodec.write(file, second)) + assertTrue(second.rawLengths.contentEquals(requireNotNull(ReadingTimeIndexCodec.read(file)).rawLengths)) + + file.writeBytes(byteArrayOf(1, 2, 3)) + assertNull(ReadingTimeIndexCodec.read(file)) + } finally { + assertTrue(directory.deleteRecursively()) + } + } + + @Test + fun `snapshot prefix queries do not mutate caller array`() { + val lengths = intArrayOf(100, -1, 0, 300) + val snapshot = ReadingTimeIndexSnapshot.create(lengths) + lengths[0] = 999 + + assertEquals(100, snapshot.rawLengths[0]) + assertEquals(2, snapshot.knownContentCount) + assertEquals(3, snapshot.contentChapterCount) + assertFalse(snapshot.hasUnknownContentBetween(2, 3)) + assertTrue(snapshot.hasUnknownContentBetween(0, 1)) + } + + @Test + fun `toc append preserves lengths while reorder resets model`() { + val oldEntries = listOf( + ReadingTimeTocEntry("0|a"), + ReadingTimeTocEntry("1|b"), + ) + val stored = ReadingTimeIndexData( + bookIdentityHash = 7L, + tocPrefixHash = ReadingTimeIndexReconciler.tocHash(oldEntries), + sourceLastModified = 0L, + rawLengths = intArrayOf(100, 200), + ) + + val appended = ReadingTimeIndexReconciler.reconcile( + stored = stored, + bookIdentityHash = 7L, + sourceLastModified = 0L, + entries = oldEntries + ReadingTimeTocEntry("2|c"), + ) + assertFalse(appended.resetSpeedModel) + assertTrue(intArrayOf(100, 200, -1).contentEquals(appended.rawLengths)) + + val reordered = ReadingTimeIndexReconciler.reconcile( + stored = stored, + bookIdentityHash = 7L, + sourceLastModified = 0L, + entries = listOf(oldEntries[1], oldEntries[0]), + ) + assertTrue(reordered.resetSpeedModel) + assertTrue(intArrayOf(-1, -1).contentEquals(reordered.rawLengths)) + } + + @Test + fun `direct local lengths override stale sidecar`() { + val oldEntries = listOf(ReadingTimeTocEntry("0|a")) + val stored = ReadingTimeIndexData( + bookIdentityHash = 7L, + tocPrefixHash = ReadingTimeIndexReconciler.tocHash(oldEntries), + sourceLastModified = 9L, + rawLengths = intArrayOf(100), + ) + + val result = ReadingTimeIndexReconciler.reconcile( + stored = stored, + bookIdentityHash = 7L, + sourceLastModified = 9L, + entries = listOf(ReadingTimeTocEntry("0|a", 250)), + ) + + assertFalse(result.resetSpeedModel) + assertTrue(intArrayOf(250).contentEquals(result.rawLengths)) + } + + @Test + fun `source identity modification and directory shrink reset the model`() { + val entries = listOf( + ReadingTimeTocEntry("0|a"), + ReadingTimeTocEntry("1|b"), + ) + val stored = ReadingTimeIndexData( + bookIdentityHash = 7L, + tocPrefixHash = ReadingTimeIndexReconciler.tocHash(entries), + sourceLastModified = 9L, + rawLengths = intArrayOf(100, 200), + ) + + val modifiedFile = ReadingTimeIndexReconciler.reconcile( + stored = stored, + bookIdentityHash = 7L, + sourceLastModified = 10L, + entries = entries, + ) + val changedSource = ReadingTimeIndexReconciler.reconcile( + stored = stored, + bookIdentityHash = 8L, + sourceLastModified = 9L, + entries = entries, + ) + val shrunkDirectory = ReadingTimeIndexReconciler.reconcile( + stored = stored, + bookIdentityHash = 7L, + sourceLastModified = 9L, + entries = entries.take(1), + ) + + assertTrue(modifiedFile.resetSpeedModel) + assertTrue(changedSource.resetSpeedModel) + assertTrue(shrunkDirectory.resetSpeedModel) + } + + @Test + fun `hybrid mode requires both chapter count and coverage thresholds`() { + val tooFewKnown = IntArray(100) { index -> + if (index < 19) 100 else ReadingTimeIndexSnapshot.UNKNOWN_LENGTH + } + val tooLittleCoverage = IntArray(101) { index -> + if (index < 20) 100 else ReadingTimeIndexSnapshot.UNKNOWN_LENGTH + } + val thresholdReached = IntArray(100) { index -> + if (index < 20) 100 else ReadingTimeIndexSnapshot.UNKNOWN_LENGTH + } + + assertEquals( + ReadingTimeEstimateMode.CHAPTER, + ReadingTimeIndexSnapshot.create(tooFewKnown).mode, + ) + assertEquals( + ReadingTimeEstimateMode.CHAPTER, + ReadingTimeIndexSnapshot.create(tooLittleCoverage).mode, + ) + assertEquals( + ReadingTimeEstimateMode.HYBRID_CONTENT, + ReadingTimeIndexSnapshot.create(thresholdReached).mode, + ) + } + + @Test + fun `speed state rejects changed local file even without a sidecar`() { + val entries = listOf(ReadingTimeTocEntry("0|a")) + val state = ReadingTimeState( + chapterSecondsPerUnit = 600.0, + sampleCount = 5, + validReadingMillis = 60_000L, + bookIdentityHash = 7L, + tocChapterCount = 1, + tocPrefixHash = ReadingTimeIndexReconciler.tocHash(entries), + sourceLastModified = 9L, + ) + + assertFalse( + ReadingTimeIndexReconciler.shouldResetSpeedState(state, 7L, 9L, entries) + ) + assertTrue( + ReadingTimeIndexReconciler.shouldResetSpeedState(state, 7L, 10L, entries) + ) + } +} diff --git a/app/src/test/java/io/legado/app/model/read/ReadingTimeReadConfigTest.kt b/app/src/test/java/io/legado/app/model/read/ReadingTimeReadConfigTest.kt new file mode 100644 index 000000000..4d855b555 --- /dev/null +++ b/app/src/test/java/io/legado/app/model/read/ReadingTimeReadConfigTest.kt @@ -0,0 +1,69 @@ +package io.legado.app.model.read + +import com.google.gson.GsonBuilder +import com.google.gson.TypeAdapter +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonWriter +import io.legado.app.data.entities.Book +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate + +class ReadingTimeReadConfigTest { + + @Test + fun `legacy read config uses empty reading time state`() { + val config = gson.fromJson("""{"reverseToc":true}""", Book.ReadConfig::class.java) + + requireNotNull(config) + assertTrue(config.reverseToc) + assertNull(config.readingTimeState) + } + + @Test + fun `reading time state survives json round trip`() { + val state = ReadingTimeState( + chapterSecondsPerUnit = 321.0, + sampleCount = 6, + validReadingMillis = 70_000L, + bookIdentityHash = 12L, + tocChapterCount = 30, + tocPrefixHash = 34L, + sourceLastModified = 56L, + ) + val restored = gson.fromJson( + gson.toJson(Book.ReadConfig(readingTimeState = state)), + Book.ReadConfig::class.java, + ) + + assertEquals(state, requireNotNull(restored).readingTimeState) + } + + @Test + fun `record disabled snapshot hides every reading time field`() { + val snapshot = ReadingTimeDisplayFormatter.unavailableSnapshot("—") + + assertEquals("—", snapshot.accumulated) + assertEquals("—", snapshot.remaining) + assertEquals("—", snapshot.combined) + } + + private val gson = GsonBuilder() + .registerTypeAdapter(LocalDate::class.java, object : TypeAdapter() { + override fun write(out: JsonWriter, value: LocalDate?) { + if (value == null) out.nullValue() else out.value(value.toString()) + } + + override fun read(input: JsonReader): LocalDate? { + return if (input.peek() == com.google.gson.stream.JsonToken.NULL) { + input.nextNull() + null + } else { + LocalDate.parse(input.nextString()) + } + } + }) + .create() +} diff --git a/openspec/changes/archive/2026-08-11-reading-time-estimation/.openspec.yaml b/openspec/changes/archive/2026-08-11-reading-time-estimation/.openspec.yaml new file mode 100644 index 000000000..a8821c74d --- /dev/null +++ b/openspec/changes/archive/2026-08-11-reading-time-estimation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-11 diff --git a/openspec/changes/archive/2026-08-11-reading-time-estimation/design.md b/openspec/changes/archive/2026-08-11-reading-time-estimation/design.md new file mode 100644 index 000000000..2c04d8309 --- /dev/null +++ b/openspec/changes/archive/2026-08-11-reading-time-estimation/design.md @@ -0,0 +1,131 @@ +## 背景与约束 + +参见 `proposal.md` 的动机以及 `specs/reading-time-estimation/spec.md` 的行为合同。现有页眉页脚由 `ReadTipConfig` 的整数信息类型驱动,`PageView.setProgress` 会为当前页及相邻页更新进度文字,并已使用 `setTextIfNotEqual` 避免相同文本重复设置。阅读位置统一保存在 `ReadBook`,累计时长通过现有 `ReadRecord` 更新;正文缓存的标准写入、覆盖和删除原语位于 `BookHelp`。 + +本设计必须兼容最低 API 21,不能修改书源规则、正文格式、导入接口或 Room schema。墨水屏设备的主要风险不是 EWMA 算术,而是把整书扫描、数据库或文件操作放入翻页主线程,或在正文已经绘制后因异步 ETA 再触发一次刷新。 + +## 目标与非目标 + +**目标:** + +- 用纯内存常数时间热路径完成有效性判断、速度更新、剩余量查询和显示快照读取。 +- 让完整缓存获得内容量精度,部分缓存可渐进改善,无正文时仍有章节级兜底。 +- 让索引可以在缓存写入、删除和目录追加时增量维护,并能在损坏或身份变化后安全重建。 +- 复用现有阅读记录、生命周期、分钟广播和文本去重更新机制。 +- 将算法核心、索引编解码和模式选择设计为可在本地 JVM 测试的纯 Kotlin 逻辑。 + +**非目标:** + +- 不把原始正文长度解释成净化后字符数或精确分页总量。 +- 不在首版建立全局读者画像、置信度 UI、漫画/音频估算或朗读速度模型。 +- 不引入性能基准模块、第三方统计依赖、数据库表或后台联网任务。 +- 不修正现有累计阅读记录的聚合键和历史数据。 + +## 技术决策 + +### 1. 用三个信息标识接入既有六槽系统 + +在 `ReadTipConfig` 中从未占用的整数开始增加三种类型,并把它们附加到 `tipValues`。`PageView.upTipStyle` 为三种类型绑定已有的 `BatteryView` 槽位;`setProgress` 只消费 `ReadBook` 已准备好的不可变显示快照,不查询数据库、不读取索引,也不在三个页面视图中重复运行估算器。 + +“重置本书阅读速度估算”放在页眉页脚信息配置对话框底部,仅在当前阅读书籍存在时可用,点击后经过确认再调用当前书模型的重置入口。这样入口与功能配置相邻,不向通用书籍菜单增加无关层级。 + +所有可见文字使用字符串资源。由于部分语言目录单独覆盖了 `read_tip` 数组,必须同步扩充每个已有覆盖数组,确保名称数量和 `tipValues` 一致;新增格式字符串至少提供基准语言和简体中文,其他语言可以在对应资源中给出等义文本或安全回退,不能在 Kotlin 中硬编码。 + +替代方案是新增专用页眉页脚组件,但会复制既有槽位、样式和去重逻辑,因此不采用。 + +### 2. 将采样、估算和显示快照分层 + +新增纯 Kotlin `ReadingTimeEstimator`,只持有原始数值状态并接收显式事件:页面开始可见、有效相邻前进、暂停/遮挡、重新排版、位置跳转、索引快照变化和重置。Android 阅读层负责辨认事件来源,估算器不读取全局 UI 状态。 + +正常页内前进、正常进入下一章和自动翻页调用有效前进入口;`skipToPage`、`openChapter`、向后移动和朗读路径调用失效或重新布点入口。Activity 生命周期、阅读菜单显示以及排版配置更新会暂停当前样本并在重新可读时重新布点。时间使用 `SystemClock.elapsedRealtime()`,避免系统时间调整污染样本。 + +`ReadingTimeDisplaySnapshot` 只包含累计时长状态、ETA 状态和已经格式化或可常数时间格式化的分钟桶。可见页面在现有正文更新 traversal 中读取它;索引后台发布新快照时只更新内存引用,不直接刷新页面。 + +替代方案是在 `PageView.setProgress` 内计算 ETA,但该方法会被当前、前一和后一页面复用,还会在布局更新中再次进入,容易造成重复计算和墨水屏二次刷新,因此不采用。 + +### 3. 使用两套稳健 EWMA 而不是总时长除以总进度 + +估算器独立维护“秒/章节当量”和“秒/原始字节”两套模型。每次样本先检查相邻向前、阅读页可见、未被遮挡、非朗读且停留时间在 5~120 秒内,再计算单位前进耗时。样本相对已有均值裁剪到 `0.25~4` 倍,随后以 `α=0.2` 更新 EWMA。 + +章节当量坐标为“章节序号 + 当前章已读比例”。内容量坐标使用后台快照中的章节原始字节数,并以当前排版内容的章内比例映射当前章字节位置。只有长度已知且位置映射有效时才更新字节模型;章节模型始终作为兜底。 + +至少 5 个有效样本且累计有效时间达到 60 秒后模型才可展示。采用增量 EWMA 是因为其更新为常数时间、状态很小且能逐步跟随近期速度;不采用保存全部样本后回归,也不采用累计阅读记录直接除以全书进度,后两者分别增加存储成本或容易被历史跳转和停留污染。 + +### 4. 用不可变索引快照实现三级自动估算 + +内存索引快照包含每章长度、已知长度前缀和、已知章节数前缀和、正文章节计数、中位章节长度、目录身份和模式。后台构建完成后原子替换快照引用,翻页只进行数组定址、前缀差和常数次数值计算。 + +模式规则如下: + +- 所有有效正文章节长度已知时使用全量内容模式。 +- 否则,已知正文章节不少于 20 且覆盖率不少于 20% 时使用混合模式;未知、VIP 和失败章节使用已知章节长度中位数补齐。 +- 其他情况使用章节等权模式。 + +中位数在后台构建快照时计算,绝不在翻页时排序。模式变化只记录新目标;下一次有效翻页开始,用连续 5 个有效样本按 `20%/40%/60%/80%/100%` 过渡到新 ETA。目录真实追加带来的剩余量增加不隐藏,并在下一次翻页直接反映。 + +替代方案是始终章节等权,无法利用用户主动离线缓存;始终内容量则会让未缓存书无法工作,因此采用自动三级降级。 + +### 5. 首版统一使用原始字节长度 + +在线章节保存成功后可以通过文件元数据取得原始 UTF-8 缓存字节数;已有缓存只需读取 `File.length()`,不读取正文。本地 TXT 的 `BookChapter.start/end` 已是字节区间,可直接使用。EPUB 优先使用已存在的章节缓存或 ZIP 条目元数据;无法低成本取得时保持未知并降级,不为 ETA 解压或解析整本书。 + +原始字节数与最终净化、简繁转换及分页坐标并不完全一致,但同一本书内编码比例通常稳定,按书训练的速度会吸收大部分常数比例差异。该选择避免批量执行 `ContentProcessor` 和重分页,是精度与墨水屏成本之间的明确取舍。 + +### 6. 逐章长度使用版本化二进制 sidecar + +在书籍现有缓存目录内新增不以章节保留后缀结尾的 `reading_time_index.bin`。版本 1 使用定长头部和每章一个有符号 32 位长度: + +```text +magic、格式版本、书籍来源摘要、目录章数、旧目录前缀摘要、 +本地文件修改标识、已知数、长度数组、CRC32 +``` + +`-1` 表示未知,`0` 表示卷标题等无正文项。读取时校验 magic、版本、身份、长度和 CRC;任何失败都丢弃索引并后台重建,绝不影响正文读取。写入先生成同目录临时文件,完成校验后再替换正式文件,进程被杀时仍可继续使用旧快照。 + +sidecar 是可重建缓存,不写入 `readConfig`、Room 或备份。放入 `readConfig` 会使大书的整行 JSON、Parcelable 和备份膨胀;新增数据库表又会引入不必要的迁移,因此均不采用。 + +### 7. 直接挂接缓存文件原语并采用单书单写者 + +长度更新直接接入 `BookHelp.saveText`、`delContent` 和整书缓存清理等文件原语,而不是把现有 `SAVE_CONTENT` 事件当作唯一真源,因为编辑正文、本地 EPUB 缓存及部分删除路径不会完整发出该事件。 + +保存成功后只向索引仓库提交“章节文件名 + 字节数”,删除时提交未知标记;这些调用不重写 sidecar。单书内存状态由单写者合并,连续下载期间最多约每 30 秒落盘一次,并在下载结束、换书或生命周期收尾时刷新。速度模型最多约每 120 秒以及暂停、换书和重置时合并保存,避免逐页更新 `Book` JSON。 + +后台首次扫描采用当前书单任务、低优先级、可取消策略:每批最多 64 章或约 8ms,随后至少让出约 50ms,并在每批检查取消和目录 generation。换书、三种信息均不再选择、低内存、缓存清理或身份变化会取消任务。后台任务不使用承载阅读记录写入的现有全局单线程执行器,避免队头阻塞。 + +### 8. 目录身份采用“只允许末尾纯追加”的失败关闭策略 + +sidecar 保存旧目录数量和旧目录前缀滚动摘要。新目录若前 N 章身份完全一致,只扩展未知项并保留旧模型;任何插入、删除、改名、改序、换源、本地文件实质变化或来源摘要不符,都删除 sidecar 并清空 `readConfig` 中的全部阅读速度状态。 + +`Book.migrateTo` 当前会复制整份 `readConfig`,实现时必须显式复制其他阅读配置但把新增估算状态置空,防止换源把模型带入新来源。纯末尾追加则保留模型,新章节在缓存成功后增量补全。 + +这是用户选择的保守策略。替代方案是保留章节速度或尝试对齐重排目录,但会增加错配风险和实现复杂度,因此不采用。 + +### 9. 小型速度模型嵌入现有本书配置 + +在 `Book.ReadConfig` 增加可空、带版本的小型 `ReadingTimeState`,只保存两套 EWMA、有效样本数、累计有效秒数和必要的模型身份。缺失字段等价于从未学习,不需要 Room schema 或迁移脚本;Gson 仍负责既有 JSON 转换,Parcelable 继续支持 Book 在 Android 组件间传递。 + +完整书架备份会自然携带该小状态,恢复后若 sidecar 不存在则先使用章节模式并按需重建。sidecar 不实时同步,也不是恢复可用性的前提。手动重置只清除 `ReadingTimeState` 和内存过渡态;累计阅读记录、缓存正文及仍然有效的长度 sidecar 保留。 + +### 10. 复用现有分钟和绘制时机 + +累计时长显示由现有阅读记录内存值与当前阅读段组合得到,并复用系统分钟广播刷新,不新增计时器或唤醒。累计与剩余时长均在不足 60 分钟时显示分钟,达到 60 分钟后显示小时和分钟;ETA 仍先向上取整到分钟。中文格式保留完整的“已读”和“剩余”文字,但不在标签、数值或组合分隔符之间插入排版空格。ETA 只在有效翻页更新,不空闲倒计时。`PageView.setProgress` 使用 `setTextIfNotEqual` 写入最终文字,保证 ETA 与正文在同一页面更新中提交。 + +阅读记录关闭时估算器立即停止采样并发布“—”状态;重新启用后重新布点,但保留此前兼容模型。组合信息由同一快照格式化,避免累计和 ETA 在一次绘制中来自不同状态。 + +## 风险与取舍 + +- **原始字节长度与净化后正文不完全一致** → 按书训练吸收稳定比例;长度无法可靠取得时自动降级,首版不以高成本正文扫描换取表面精度。 +- **页面变化来源分类错误会污染速度** → 只在明确的相邻向前入口训练,所有跳转、初始化、排版和朗读路径默认失败关闭,并用单元测试覆盖事件状态机。 +- **异步索引可能与缓存下载或目录更新竞争** → 使用单书单写者、generation、不可变快照和临时文件替换;旧 generation 的结果不得发布。 +- **大目录增加内存和后台扫描时间** → 紧凑 primitive 数组使空间保持线性且量级接近现有章节列表;扫描分片、低优先级且可取消,翻页不等待。 +- **局部标题修正也会触发保守重置** → 这是换取不复用错误模型的已确认行为;只有可证明的末尾纯追加保留状态。 +- **多语言 `read_tip` 数组长度不一致会导致选择错位** → 同步更新所有覆盖该数组的资源目录,并加入数组与 ID 数量一致性测试。 +- **旧版本回退后会忽略新增 JSON 字段和 sidecar** → 新字段可空且 sidecar 独立,回退不会损坏正文和阅读记录;再次升级时可继续读取兼容状态或安全重建。 + +## 迁移与回退计划 + +1. 发布后,旧书籍没有 `ReadingTimeState`,首次选择相关信息时从“学习中”开始并后台建立索引。 +2. 已有缓存不做启动时全量迁移,只在当前书实际选择相关信息后按需扫描;新缓存章节走增量路径。 +3. 不修改 Room schema,因此不需要数据库迁移和 schema 快照。 +4. 若新逻辑出现问题,可以停止选择新增信息并取消索引任务;删除 sidecar 即可回到无索引状态,不影响正文。 +5. 代码回退时旧版本忽略 `readConfig` 的未知 JSON 字段及独立 sidecar;缓存清理会最终删除 sidecar。 diff --git a/openspec/changes/archive/2026-08-11-reading-time-estimation/proposal.md b/openspec/changes/archive/2026-08-11-reading-time-estimation/proposal.md new file mode 100644 index 000000000..8d417631d --- /dev/null +++ b/openspec/changes/archive/2026-08-11-reading-time-estimation/proposal.md @@ -0,0 +1,36 @@ +## 为什么 + +当前阅读页的页眉页脚可以显示章节、进度和时间等信息,但无法展示用户已经投入的阅读时长,也无法根据个人阅读速度动态估算读完整本书所需的剩余时间。增加按书学习且持续更新的阅读时间信息,可以让本地书和在线文本书获得类似 Kindle 的时间反馈,同时不能给墨水屏翻页热路径引入明显负担。 + +## 变更内容 + +- 在“页面 → 信息 → 页眉页脚”增加“累计阅读时长”“预计剩余阅读时间”“累计与剩余阅读时间”三个独立信息项。 +- 沿用现有阅读记录作为累计时长来源,并为每本书建立可重置的自适应阅读速度模型;达到学习门槛前显示“学习中”。 +- 根据正文可用程度自动使用全量内容、部分缓存混合或章节等权三种剩余时间估算模式,并在有效向前翻页后实时更新结果。 +- 为本地 TXT、在线缓存和可低成本读取元数据的 EPUB 建立按需、增量、可取消的章节长度索引;不会为了估算主动下载、净化或重新分页整本正文。 +- 将小型速度模型保存在本书 `readConfig`,将可重建的逐章长度索引保存在书籍缓存目录的版本化二进制 sidecar 中。 +- 正常手动翻页和自动翻页参与训练;回退、跳转、后台停留、菜单遮挡、朗读翻页、排版重建及初始化回调不参与训练。 +- 保持翻页热路径为常数时间和常数额外空间,不在翻页时执行文件、数据库、网络、JSON 或章节遍历操作。 +- 新增“重置本书阅读速度估算”操作;它不删除累计阅读记录或正文缓存。 + +明确的非目标:不复刻或宣称掌握 Kindle 私有公式;不覆盖漫画、音频书和朗读速度估算;不修复现有阅读记录按书名汇总等历史语义;不新增跨书速度模型、Room 表或第三方依赖;不实时同步可重建的长度索引。 + +## 能力 + +### 新增能力 + +- `reading-time-estimation`:定义文本阅读页的累计时长展示、按书自适应速度学习、三级剩余时间估算、索引生命周期、重置行为和墨水屏性能边界。 + +### 修改能力 + +无。当前仓库尚无需要修改的现有 OpenSpec 能力。 + +## 影响 + +- 受影响模块仅为 Android `app` 模块,主要涉及页眉页脚配置与渲染、阅读页翻页事件、本书 `readConfig`、正文缓存读写及聚焦的单元测试和人工验证;`modules/book`、`modules/rhino` 和 `modules/web` 不在范围内。 +- 新字段位于已有 JSON 配置中并提供缺省值,不修改 Room schema、书源规则、导入 URI 或正文格式;旧版本忽略新增字段,新版本可读取没有新增字段的旧数据。 +- 小型速度状态随现有完整书架备份迁移;长度 sidecar 属于可删除重建的缓存,不进入备份,也不包含正文内容。 +- 换源或非末尾追加的目录变化会拒绝复用旧索引并清除全部速度模型;目录末尾纯追加保留既有模型并增量扩展索引。 +- 索引文件仅写入应用现有的书籍缓存目录,使用校验和临时文件替换避免损坏正文缓存;本变更不扩大网络、脚本或外部文件访问权限。 + +用户可观察的验收条件:三个信息项均可独立选择且不会破坏现有六槽去重行为;学习期间、记录关闭、读完及重置状态显示符合规范;有效翻页会使估算随个人速度调整,无效行为不会训练;完整缓存比无缓存使用更细的内容量估算;墨水屏翻页不因索引完成产生额外独立刷新,翻页时不发生新增磁盘、数据库或网络访问。 diff --git a/openspec/changes/archive/2026-08-11-reading-time-estimation/specs/reading-time-estimation/spec.md b/openspec/changes/archive/2026-08-11-reading-time-estimation/specs/reading-time-estimation/spec.md new file mode 100644 index 000000000..e838be21a --- /dev/null +++ b/openspec/changes/archive/2026-08-11-reading-time-estimation/specs/reading-time-estimation/spec.md @@ -0,0 +1,160 @@ +## Purpose + +为文本阅读器提供按书累计时长展示和基于个人有效阅读行为的动态剩余时间估算,并在正文完整度不同及墨水屏性能受限的环境下保持一致、可降级和可验证的用户体验。 + +## ADDED Requirements + +### Requirement: 页眉页脚提供三种阅读时间信息 +系统 MUST 在“页面 → 信息 → 页眉页脚”的既有信息选择列表中提供“累计阅读时长”“预计剩余阅读时间”“累计与剩余阅读时间”三个独立信息项,并保持既有槽位选择和去重规则。 + +#### Scenario: 分别选择三种信息 +- **WHEN** 用户在任一页眉页脚槽位选择一种新增信息 +- **THEN** 系统在该槽位显示对应的累计时长、预计剩余时间或二者组合内容 + +#### Scenario: 保持既有去重行为 +- **WHEN** 用户把已经由其他槽位使用的信息分配到当前槽位 +- **THEN** 系统按照既有规则移除或调整重复选择,且新增信息不绕过去重约束 + +### Requirement: 阅读时间采用紧凑且稳定的显示状态 +系统 MUST 使用不含“约”字且不插入排版空格的紧凑中文格式显示时长;不足 60 分钟时使用分钟,达到 60 分钟后使用小时和分钟。预计剩余时间 MUST 向上取整到分钟,未读完时最低显示 1 分钟,读完时显示 0 分钟。 + +#### Scenario: 显示可用的阅读时间 +- **WHEN** 累计时长为 80 分钟且预计剩余时间为 45 分钟 +- **THEN** 分开信息分别显示“已读1时20分”和“剩余45分”,组合信息显示“已读1时20分·剩余45分” + +#### Scenario: 剩余时间不足一分钟 +- **WHEN** 书籍尚未读完且估算结果大于 0 秒但不足 60 秒 +- **THEN** 系统显示“剩余1分” + +#### Scenario: 剩余时间达到一小时 +- **WHEN** 预计剩余时间向上取整后为 3324 分钟 +- **THEN** 系统显示“剩余55时24分”,而不是“剩余3324分” + +#### Scenario: 完成阅读 +- **WHEN** 当前阅读位置已经到达全书末尾 +- **THEN** 系统显示“剩余0分” + +### Requirement: 累计时长沿用现有阅读记录 +系统 MUST 使用现有阅读记录的累计分钟数作为“累计阅读时长”的数据来源,不建立具有不同统计语义的第二套累计记录。 + +#### Scenario: 现有记录继续累计 +- **WHEN** 现有阅读记录产生新的分钟更新 +- **THEN** 累计阅读时长信息使用更新后的既有累计值,并且不创建额外的逐页累计记录 + +#### Scenario: 阅读记录关闭 +- **WHEN** 用户关闭阅读记录功能 +- **THEN** 系统停止学习阅读速度,所有新增阅读时间信息显示“—” + +### Requirement: 每本书独立学习有效阅读速度 +系统 MUST 为每本书独立学习阅读速度;正常相邻向前翻页和自动翻页 MUST 参与训练,回退、位置跳转、后台或遮挡停留、朗读翻页、排版变化及初始化回调 MUST NOT 参与训练。 + +#### Scenario: 正常向前阅读形成样本 +- **WHEN** 用户在允许的停留时间范围内完成一次正常相邻向前翻页 +- **THEN** 系统把本次有效停留时间和前进量加入当前书的速度模型 + +#### Scenario: 自动翻页形成样本 +- **WHEN** 自动翻页在阅读页面可见期间正常前进一页 +- **THEN** 系统按照与手动前进相同的有效性规则训练当前书速度 + +#### Scenario: 非阅读行为不形成样本 +- **WHEN** 页面变化由回退、目录或进度条跳转、朗读、排版重建、初始化或遮挡期间停留引起 +- **THEN** 系统不使用该时间段更新任何速度模型 + +### Requirement: 学习达到最低证据后才显示预计值 +系统 MUST 在当前书至少取得 5 个有效前进样本且累计有效学习时间不少于 60 秒后才显示预计剩余时间;此前 MUST 显示“学习中”。 + +#### Scenario: 样本不足 +- **WHEN** 有效样本数少于 5 个或累计有效学习时间少于 60 秒 +- **THEN** 预计剩余阅读时间显示“学习中” + +#### Scenario: 达到学习门槛 +- **WHEN** 有效样本数达到 5 个且累计有效学习时间达到 60 秒 +- **THEN** 系统从下一次有效页面更新开始显示动态预计剩余时间 + +### Requirement: 系统自动选择三级剩余量估算 +系统 MUST 根据当前书可用的章节长度信息自动在全量内容模式、部分缓存混合模式和章节等权模式之间选择,不要求用户手动指定模式。 + +#### Scenario: 全量内容可用 +- **WHEN** 所有有效正文章节的原始长度都已知 +- **THEN** 系统按照当前章剩余比例和后续章节实际内容量估算全书剩余量 + +#### Scenario: 部分内容可用 +- **WHEN** 已知章节数量和覆盖率达到混合模式门槛但仍存在未知章节 +- **THEN** 系统对已知章节使用实际长度,并用本书已知章节长度中位数补齐未缓存、付费或读取失败的章节 + +#### Scenario: 正文信息不足 +- **WHEN** 可用正文长度未达到混合模式门槛 +- **THEN** 系统按照当前章节序号和章内阅读比例使用章节等权估算 + +### Requirement: 建立估算信息不得主动获取内容 +系统 MUST 仅使用本地已有正文、缓存或低成本文件元数据建立长度信息,不得为了预计剩余时间主动发起网络下载,也不得批量执行正文净化、转换、重新分段或分页。 + +#### Scenario: 在线章节尚未缓存 +- **WHEN** 用户选择预计剩余时间信息但在线书仍有未缓存章节 +- **THEN** 系统使用混合或章节模式,并且不为建立估算索引下载这些章节 + +#### Scenario: 本地格式无法低成本取得长度 +- **WHEN** 某本地格式无法从已有目录、缓存或容器元数据取得章节长度 +- **THEN** 系统降级使用部分内容或章节模式,而不是扫描和完整解析整本正文 + +### Requirement: 预计时间随阅读行为更新且避免独立刷新 +系统 MUST 在每次有效翻页后重新计算预计剩余时间,并且只有分钟显示值发生变化时才更新文字;后台索引完成 MUST NOT 单独触发阅读页面刷新。 + +#### Scenario: 阅读速度变化 +- **WHEN** 用户近期有效阅读速度持续快于或慢于既有模型 +- **THEN** 后续有效翻页显示的预计剩余时间逐步向近期速度对应的结果调整 + +#### Scenario: 估算模式发生变化 +- **WHEN** 后台长度信息使估算从章节模式切换到混合或全量模式 +- **THEN** 系统从下一次有效翻页开始在 5 个有效样本内平滑采用新结果,不立即单独刷新屏幕 + +#### Scenario: 分钟显示值未变化 +- **WHEN** 新估算与当前显示向上取整后为同一分钟数 +- **THEN** 系统不重复更新页眉或页脚文本 + +### Requirement: 目录变化采用可预测的保留和重置规则 +系统 MUST 仅在原目录前缀完全一致且新章节位于末尾时保留既有索引和速度模型;换源及任何非末尾追加的目录变化 MUST 清除长度索引和全部速度模型。 + +#### Scenario: 连载末尾追加章节 +- **WHEN** 新目录完整保留旧目录前缀并只在末尾追加章节 +- **THEN** 系统保留已有速度和长度信息,增量加入新章节,并在下一次翻页体现增加的剩余量 + +#### Scenario: 换源或目录重排 +- **WHEN** 书籍换源,或目录发生插入、删除、改序等非末尾追加变化 +- **THEN** 系统清除该书全部估算模型和索引,并重新显示“学习中” + +### Requirement: 用户可以仅重置本书速度估算 +系统 MUST 提供重置当前书阅读速度估算的操作,并且该操作 MUST NOT 删除累计阅读记录或正文缓存。 + +#### Scenario: 手动重置估算 +- **WHEN** 用户确认重置当前书阅读速度估算 +- **THEN** 系统清除该书的速度和学习样本并显示“学习中”,同时保留累计阅读记录和正文缓存 + +### Requirement: 估算状态兼容现有数据和备份 +系统 MUST 允许没有新增状态的旧书籍配置继续读取,并使小型速度状态随现有完整书架备份迁移;可重建的章节长度信息 MUST NOT 成为恢复备份的必要条件。 + +#### Scenario: 读取旧版本书籍数据 +- **WHEN** 本书配置中不存在阅读速度估算字段 +- **THEN** 系统使用未学习的默认状态打开书籍且不要求数据库迁移 + +#### Scenario: 从完整备份恢复 +- **WHEN** 用户恢复包含已学习速度状态的完整书架备份 +- **THEN** 系统恢复兼容的速度状态,并在本地按需重建缺失的章节长度信息 + +### Requirement: 翻页热路径保持常数复杂度 +系统 MUST 使每次翻页新增的估算工作保持常数时间和常数额外空间,并且 MUST NOT 在翻页热路径执行文件或数据库访问、网络请求、JSON 处理或章节遍历。 + +#### Scenario: 墨水屏正常翻页 +- **WHEN** 用户在墨水屏模式下连续翻页并显示预计剩余时间 +- **THEN** 每页估算只读取内存状态、执行常数次数值计算并按需更新一次显示文字,不等待后台索引或持久化 + +#### Scenario: 建立全书长度信息 +- **WHEN** 系统需要遍历已有章节元数据建立或修复索引 +- **THEN** 该工作在单个低优先级、分片且可取消的后台任务中进行,不阻塞阅读翻页 + +### Requirement: 首版仅支持文本阅读器 +系统 MUST 将速度学习和剩余时间估算限制在本地及在线文本阅读器;漫画、音频书和朗读过程不属于首版训练范围。 + +#### Scenario: 不支持的阅读类型 +- **WHEN** 当前内容由漫画或音频阅读器展示,或者页面正由朗读驱动前进 +- **THEN** 系统不采集新的文本阅读速度样本,且不得因缺少文本索引发生错误 diff --git a/openspec/changes/archive/2026-08-11-reading-time-estimation/tasks.md b/openspec/changes/archive/2026-08-11-reading-time-estimation/tasks.md new file mode 100644 index 000000000..5676cf242 --- /dev/null +++ b/openspec/changes/archive/2026-08-11-reading-time-estimation/tasks.md @@ -0,0 +1,42 @@ +## 1. 估算模型与格式化 + +- [x] 1.1 定义带版本的本书阅读速度状态、索引快照、估算模式和显示状态,保持数据结构仅包含小型 primitive 状态。 +- [x] 1.2 实现纯 Kotlin 的有效样本状态机、5~120 秒过滤、异常裁剪、双 EWMA 更新以及 5 样本和 60 秒学习门槛。 +- [x] 1.3 实现章节等权、部分缓存混合和全量内容三种剩余量计算、自动选级以及 5 个有效样本的模式切换平滑。 +- [x] 1.4 实现累计、学习中、不可用、剩余分钟向上取整、超过 60 分钟的小时格式和中文无排版空格的组合信息格式化逻辑。 +- [x] 1.5 为样本过滤、EWMA、学习门槛、三级估算、模式平滑、完成状态和分钟到小时格式增加聚焦的 JVM 单元测试。 + +## 2. 章节长度索引 + +- [x] 2.1 实现带 magic、版本、来源和目录身份、长度数组及 CRC32 的二进制 sidecar 编解码,并采用临时文件原子替换。 +- [x] 2.2 实现不可变前缀快照和后台中位长度计算,使翻页查询保持常数时间且不现场遍历章节。 +- [x] 2.3 实现当前书单任务、低优先级、分片和可取消的首次索引;本地 TXT 使用 `start/end`,在线缓存使用文件元数据,EPUB 仅使用低成本可得的缓存或容器元数据。 +- [x] 2.4 在 `BookHelp` 正文写入、覆盖、单章删除和整书清理原语接入 O(1) 增量通知,并通过单书单写者和合并落盘避免批量缓存写放大。 +- [x] 2.5 实现目录末尾纯追加的保留逻辑,以及换源、本地文件变化和任意非末尾目录变化的索引与全部速度模型重置。 +- [x] 2.6 为 sidecar 往返、损坏和截断、CRC、版本、末尾追加、目录重排、章节写入和删除增加聚焦测试。 + +## 3. 阅读生命周期与持久化 + +- [x] 3.1 在 `Book.ReadConfig` 增加可空的小型阅读速度状态,验证旧 JSON、Parcelable 和完整备份兼容且不修改 Room schema。 +- [x] 3.2 调整换源迁移,只复制其他阅读配置并清空新增估算状态,防止旧速度随 `Book.migrateTo` 进入新来源。 +- [x] 3.3 在正常页内前进、正常下一章和自动翻页路径采样,并在回退、跳转、朗读、排版、菜单遮挡、后台和初始化路径暂停或重新布点。 +- [x] 3.4 将速度状态按时间和生命周期合并持久化,禁止每页执行数据库、JSON、文件或新协程操作。 +- [x] 3.5 复用现有阅读记录内存状态和分钟事件生成累计时长,并将 ETA 与累计值组成供页面读取的单一显示快照。 + +## 4. 页眉页脚界面 + +- [x] 4.1 为三种信息分配稳定 ID 并接入现有六槽选择和去重逻辑,同步所有覆盖 `read_tip` 的多语言数组。 +- [x] 4.2 在 `PageView` 绑定三种信息并只读取预计算快照,复用 `setTextIfNotEqual`,确保索引完成不单独触发页面刷新。 +- [x] 4.3 在页眉页脚配置对话框增加带确认的“重置本书阅读速度估算”,只清除速度学习状态而保留累计记录和正文缓存。 +- [x] 4.4 补齐基准语言、简体中文及已有相关语言目录的选项名称、显示格式、学习中和重置提示资源,避免业务代码出现可见硬编码。 +- [x] 4.5 增加信息 ID 与资源数组数量一致性、重置范围和记录关闭状态的自动化测试。 + +## 5. 集成验证与交付检查 + +- [x] 5.1 验证完整缓存、部分缓存、无缓存、VIP 或失败章节、连载追加、换源和目录重排的模式与失效行为。 +- [x] 5.2 验证手动翻页、自动翻页、回退、跳章、朗读、菜单遮挡、后台恢复和排版变化的训练边界。 +- [x] 5.3 在普通设备和可用的墨水屏环境检查三个显示项、滚动模式、分钟去重及无索引完成后二次闪屏;若没有墨水屏真机,在交付记录中明确未执行项。 +- [x] 5.4 运行改动范围内的 Android 单元测试,并根据结果运行最小充分的 lint 或调试包构建;不得把未运行检查报告为通过。 +- [x] 5.5 运行 `openspec validate --all --strict`、`git diff --check` 和变更范围检查,确认没有依赖升级、Room schema 变化或无关生成文件。 + +设备验证记录:2026-08-11 已在海信 HLTE556N 墨水屏设备(Android 11)验证:本地 12 章测试 TXT 能识别目录,并在达到 5 个有效样本和 60 秒后显示剩余时间。随后用 681 章真实 TXT 发现章节索引回调绑定到短生命周期启动协程的竞争,导致大目录可能持续显示“—”;改为由 `ReadBook` 长期作用域接收回调后,设备复测已正常显示预计值。长时 ETA 从 3324 分钟调整为小时和分钟后已确认正常,中文无排版空格格式也已确认正常。累计、剩余及组合三项复用同一显示快照和绑定路径;翻页与滚动模式复用 `setProgress`,分钟文本通过 `setTextIfNotEqual` 去重,索引完成只更新内存快照而不独立刷新页面。多轮安装、重开和翻页未观察或报告额外闪屏、卡顿及显示异常。 diff --git a/openspec/specs/reading-time-estimation/spec.md b/openspec/specs/reading-time-estimation/spec.md new file mode 100644 index 000000000..46c095502 --- /dev/null +++ b/openspec/specs/reading-time-estimation/spec.md @@ -0,0 +1,162 @@ +# reading-time-estimation Specification + +## Purpose + +为文本阅读器提供按书累计时长展示和基于个人有效阅读行为的动态剩余时间估算,并在正文完整度不同及墨水屏性能受限的环境下保持一致、可降级和可验证的用户体验。 + +## Requirements + +### Requirement: 页眉页脚提供三种阅读时间信息 +系统 MUST 在“页面 → 信息 → 页眉页脚”的既有信息选择列表中提供“累计阅读时长”“预计剩余阅读时间”“累计与剩余阅读时间”三个独立信息项,并保持既有槽位选择和去重规则。 + +#### Scenario: 分别选择三种信息 +- **WHEN** 用户在任一页眉页脚槽位选择一种新增信息 +- **THEN** 系统在该槽位显示对应的累计时长、预计剩余时间或二者组合内容 + +#### Scenario: 保持既有去重行为 +- **WHEN** 用户把已经由其他槽位使用的信息分配到当前槽位 +- **THEN** 系统按照既有规则移除或调整重复选择,且新增信息不绕过去重约束 + +### Requirement: 阅读时间采用紧凑且稳定的显示状态 +系统 MUST 使用不含“约”字且不插入排版空格的紧凑中文格式显示时长;不足 60 分钟时使用分钟,达到 60 分钟后使用小时和分钟。预计剩余时间 MUST 向上取整到分钟,未读完时最低显示 1 分钟,读完时显示 0 分钟。 + +#### Scenario: 显示可用的阅读时间 +- **WHEN** 累计时长为 80 分钟且预计剩余时间为 45 分钟 +- **THEN** 分开信息分别显示“已读1时20分”和“剩余45分”,组合信息显示“已读1时20分·剩余45分” + +#### Scenario: 剩余时间不足一分钟 +- **WHEN** 书籍尚未读完且估算结果大于 0 秒但不足 60 秒 +- **THEN** 系统显示“剩余1分” + +#### Scenario: 剩余时间达到一小时 +- **WHEN** 预计剩余时间向上取整后为 3324 分钟 +- **THEN** 系统显示“剩余55时24分”,而不是“剩余3324分” + +#### Scenario: 完成阅读 +- **WHEN** 当前阅读位置已经到达全书末尾 +- **THEN** 系统显示“剩余0分” + +### Requirement: 累计时长沿用现有阅读记录 +系统 MUST 使用现有阅读记录的累计分钟数作为“累计阅读时长”的数据来源,不建立具有不同统计语义的第二套累计记录。 + +#### Scenario: 现有记录继续累计 +- **WHEN** 现有阅读记录产生新的分钟更新 +- **THEN** 累计阅读时长信息使用更新后的既有累计值,并且不创建额外的逐页累计记录 + +#### Scenario: 阅读记录关闭 +- **WHEN** 用户关闭阅读记录功能 +- **THEN** 系统停止学习阅读速度,所有新增阅读时间信息显示“—” + +### Requirement: 每本书独立学习有效阅读速度 +系统 MUST 为每本书独立学习阅读速度;正常相邻向前翻页和自动翻页 MUST 参与训练,回退、位置跳转、后台或遮挡停留、朗读翻页、排版变化及初始化回调 MUST NOT 参与训练。 + +#### Scenario: 正常向前阅读形成样本 +- **WHEN** 用户在允许的停留时间范围内完成一次正常相邻向前翻页 +- **THEN** 系统把本次有效停留时间和前进量加入当前书的速度模型 + +#### Scenario: 自动翻页形成样本 +- **WHEN** 自动翻页在阅读页面可见期间正常前进一页 +- **THEN** 系统按照与手动前进相同的有效性规则训练当前书速度 + +#### Scenario: 非阅读行为不形成样本 +- **WHEN** 页面变化由回退、目录或进度条跳转、朗读、排版重建、初始化或遮挡期间停留引起 +- **THEN** 系统不使用该时间段更新任何速度模型 + +### Requirement: 学习达到最低证据后才显示预计值 +系统 MUST 在当前书至少取得 5 个有效前进样本且累计有效学习时间不少于 60 秒后才显示预计剩余时间;此前 MUST 显示“学习中”。 + +#### Scenario: 样本不足 +- **WHEN** 有效样本数少于 5 个或累计有效学习时间少于 60 秒 +- **THEN** 预计剩余阅读时间显示“学习中” + +#### Scenario: 达到学习门槛 +- **WHEN** 有效样本数达到 5 个且累计有效学习时间达到 60 秒 +- **THEN** 系统从下一次有效页面更新开始显示动态预计剩余时间 + +### Requirement: 系统自动选择三级剩余量估算 +系统 MUST 根据当前书可用的章节长度信息自动在全量内容模式、部分缓存混合模式和章节等权模式之间选择,不要求用户手动指定模式。 + +#### Scenario: 全量内容可用 +- **WHEN** 所有有效正文章节的原始长度都已知 +- **THEN** 系统按照当前章剩余比例和后续章节实际内容量估算全书剩余量 + +#### Scenario: 部分内容可用 +- **WHEN** 已知章节数量和覆盖率达到混合模式门槛但仍存在未知章节 +- **THEN** 系统对已知章节使用实际长度,并用本书已知章节长度中位数补齐未缓存、付费或读取失败的章节 + +#### Scenario: 正文信息不足 +- **WHEN** 可用正文长度未达到混合模式门槛 +- **THEN** 系统按照当前章节序号和章内阅读比例使用章节等权估算 + +### Requirement: 建立估算信息不得主动获取内容 +系统 MUST 仅使用本地已有正文、缓存或低成本文件元数据建立长度信息,不得为了预计剩余时间主动发起网络下载,也不得批量执行正文净化、转换、重新分段或分页。 + +#### Scenario: 在线章节尚未缓存 +- **WHEN** 用户选择预计剩余时间信息但在线书仍有未缓存章节 +- **THEN** 系统使用混合或章节模式,并且不为建立估算索引下载这些章节 + +#### Scenario: 本地格式无法低成本取得长度 +- **WHEN** 某本地格式无法从已有目录、缓存或容器元数据取得章节长度 +- **THEN** 系统降级使用部分内容或章节模式,而不是扫描和完整解析整本正文 + +### Requirement: 预计时间随阅读行为更新且避免独立刷新 +系统 MUST 在每次有效翻页后重新计算预计剩余时间,并且只有分钟显示值发生变化时才更新文字;后台索引完成 MUST NOT 单独触发阅读页面刷新。 + +#### Scenario: 阅读速度变化 +- **WHEN** 用户近期有效阅读速度持续快于或慢于既有模型 +- **THEN** 后续有效翻页显示的预计剩余时间逐步向近期速度对应的结果调整 + +#### Scenario: 估算模式发生变化 +- **WHEN** 后台长度信息使估算从章节模式切换到混合或全量模式 +- **THEN** 系统从下一次有效翻页开始在 5 个有效样本内平滑采用新结果,不立即单独刷新屏幕 + +#### Scenario: 分钟显示值未变化 +- **WHEN** 新估算与当前显示向上取整后为同一分钟数 +- **THEN** 系统不重复更新页眉或页脚文本 + +### Requirement: 目录变化采用可预测的保留和重置规则 +系统 MUST 仅在原目录前缀完全一致且新章节位于末尾时保留既有索引和速度模型;换源及任何非末尾追加的目录变化 MUST 清除长度索引和全部速度模型。 + +#### Scenario: 连载末尾追加章节 +- **WHEN** 新目录完整保留旧目录前缀并只在末尾追加章节 +- **THEN** 系统保留已有速度和长度信息,增量加入新章节,并在下一次翻页体现增加的剩余量 + +#### Scenario: 换源或目录重排 +- **WHEN** 书籍换源,或目录发生插入、删除、改序等非末尾追加变化 +- **THEN** 系统清除该书全部估算模型和索引,并重新显示“学习中” + +### Requirement: 用户可以仅重置本书速度估算 +系统 MUST 提供重置当前书阅读速度估算的操作,并且该操作 MUST NOT 删除累计阅读记录或正文缓存。 + +#### Scenario: 手动重置估算 +- **WHEN** 用户确认重置当前书阅读速度估算 +- **THEN** 系统清除该书的速度和学习样本并显示“学习中”,同时保留累计阅读记录和正文缓存 + +### Requirement: 估算状态兼容现有数据和备份 +系统 MUST 允许没有新增状态的旧书籍配置继续读取,并使小型速度状态随现有完整书架备份迁移;可重建的章节长度信息 MUST NOT 成为恢复备份的必要条件。 + +#### Scenario: 读取旧版本书籍数据 +- **WHEN** 本书配置中不存在阅读速度估算字段 +- **THEN** 系统使用未学习的默认状态打开书籍且不要求数据库迁移 + +#### Scenario: 从完整备份恢复 +- **WHEN** 用户恢复包含已学习速度状态的完整书架备份 +- **THEN** 系统恢复兼容的速度状态,并在本地按需重建缺失的章节长度信息 + +### Requirement: 翻页热路径保持常数复杂度 +系统 MUST 使每次翻页新增的估算工作保持常数时间和常数额外空间,并且 MUST NOT 在翻页热路径执行文件或数据库访问、网络请求、JSON 处理或章节遍历。 + +#### Scenario: 墨水屏正常翻页 +- **WHEN** 用户在墨水屏模式下连续翻页并显示预计剩余时间 +- **THEN** 每页估算只读取内存状态、执行常数次数值计算并按需更新一次显示文字,不等待后台索引或持久化 + +#### Scenario: 建立全书长度信息 +- **WHEN** 系统需要遍历已有章节元数据建立或修复索引 +- **THEN** 该工作在单个低优先级、分片且可取消的后台任务中进行,不阻塞阅读翻页 + +### Requirement: 首版仅支持文本阅读器 +系统 MUST 将速度学习和剩余时间估算限制在本地及在线文本阅读器;漫画、音频书和朗读过程不属于首版训练范围。 + +#### Scenario: 不支持的阅读类型 +- **WHEN** 当前内容由漫画或音频阅读器展示,或者页面正由朗读驱动前进 +- **THEN** 系统不采集新的文本阅读速度样本,且不得因缺少文本索引发生错误