Просмотр исходного кода

fix(read-record): 修复跨设备记录合并、时长统计及合并弹窗问题 (#2093)

* fix(read-record): 修复跨设备阅读记录合并及时长统计

- 修复跨设备聚合记录无法正确查找合并候选的问题
- 合并记录时区分阅读会话时长与旧版历史时长,避免重复计算或丢失累计时长
- 区分主动合并与备份恢复的时长重算策略
- 清理跨设备相同阅读会话,支持按书籍删除重复会话记录
- 完善阅读记录修复流程中的汇总、详情及会话数据校验
- 新增阅读时长计算和阅读记录仓库合并测试

* fix(read-record): 优化合并阅读记录弹窗

- 增加候选书名和作者筛选
- 支持整行复选交互
- 优化书名、作者和阅读时间布局
- 增加候选列表快速滚动条

* fix(read-record): 修复删除阅读记录后残留汇总时长

区分旧版无阅读时段的历史时长与现有阅读会话产生的汇总时长。

删除最后一条明细或阅读时段后,同步清理无来源的汇总记录、每日明细和时间线数据,避免首页、阅读总览及最后阅读视图继续显示已删除的书籍。

补充纯会话记录删除和保留旧版历史时长的回归测试。

* test(read-record): 修复 PR 新增测试编译与运行问题

- ReadRecordTimeTotalsTest 改用项目统一的 JUnit4(org.junit),
  仓库未引入 kotlin-test 依赖
- ReadRecordRepositoryTest 中 ReadRecordSession 改用命名参数构造,
  修复 id(Long) 与 deviceId(String) 位置混淆导致的编译错误
- setUp 补 AppConfigStore.init(生产在 App.onCreate 首行完成),
  修复 ReadRecordRepository 构造即求值 readRecordEnabled 的运行时崩溃
- 补充跨设备去重后副本设备保留旧版 legacy 时长的回归测试

* fix(read-record): 合并无反馈提示与候选弹窗选择交互

- mergeIndependentReadRecordsInto 返回是否实际发生合并:
  目标设备缺失或源记录已消失时不再静默 return,
  ViewModel 发 ShowError 提示(Screen fallback operation_failed)
- 合并候选弹窗去掉整行 toggleable 双语义,
  选中交互只由 AppCheckbox 承担

---------

Co-authored-by: HapeLee <63206378+HapeLee@users.noreply.github.com>
懒得起名字 2 месяцев назад
Родитель
Сommit
b5ef89fd6a

+ 29 - 2
app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt

@@ -184,6 +184,19 @@ interface ReadRecordDao {
         bookAuthor: String
     ): List<ReadRecord>
 
+    /** 聚合列表项没有真实设备 ID,跨设备查询可供合并的其他记录。 */
+    @Query(
+        """
+        SELECT * FROM readRecord
+        WHERE NOT (bookName = :bookName AND bookAuthor = :bookAuthor)
+        ORDER BY CASE WHEN bookName = :bookName THEN 0 ELSE 1 END, lastRead DESC
+        """
+    )
+    suspend fun getMergeCandidatesAcrossDevices(
+        bookName: String,
+        bookAuthor: String,
+    ): List<ReadRecord>
+
     /**
      * 获取某一天某一本书的所有阅读时段记录
      */
@@ -254,18 +267,32 @@ interface ReadRecordDao {
     @Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor")
     suspend fun getSessionsByBook(deviceId: String, bookName: String, bookAuthor: String): List<ReadRecordSession>
 
-    /** 清理同步产生的、字段完全相同的阅读时段记录,只保留最早写入的一行。 */
+    /** 清理跨设备同步产生的重复阅读时段(忽略 deviceId),只保留最早写入的一行。 */
     @Query(
         """
         DELETE FROM readRecordSession
         WHERE id NOT IN (
             SELECT MIN(id) FROM readRecordSession
-            GROUP BY deviceId, bookName, bookAuthor, startTime, endTime, words
+            GROUP BY bookName, bookAuthor, startTime, endTime, words
         )
         """
     )
     suspend fun deleteDuplicateSessions()
 
+    /** 阅读记录合并后,仅清理目标设备下该书的重复时段。 */
+    @Query(
+        """
+        DELETE FROM readRecordSession
+        WHERE id NOT IN (
+            SELECT MIN(id) FROM readRecordSession
+            WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor
+            GROUP BY bookName, bookAuthor, startTime, endTime, words
+        )
+        AND deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor
+        """
+    )
+    suspend fun deleteDuplicateSessionsByBook(deviceId: String, bookName: String, bookAuthor: String)
+
     @Query(
         """
         SELECT * FROM readRecordSession

+ 10 - 0
app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordTimeTotals.kt

@@ -0,0 +1,10 @@
+package io.legado.app.data.entities.readRecord
+
+/** 主动合并时计算汇总中未被阅读时段覆盖的旧版历史时长。备份恢复不使用此策略。 */
+object ReadRecordTimeTotals {
+    fun legacy(readTime: Long, sessionTime: Long): Long =
+        (readTime - sessionTime).coerceAtLeast(0L)
+
+    fun total(sessionTime: Long, legacyReadTime: Long): Long =
+        sessionTime + legacyReadTime.coerceAtLeast(0L)
+}

+ 222 - 117
app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt

@@ -11,6 +11,7 @@ import io.legado.app.data.entities.readRecord.ReadRecordSession
 import io.legado.app.data.entities.readRecord.ReadRecordTimelineDay
 import io.legado.app.data.entities.readRecord.ReadRecordIdentity
 import io.legado.app.data.entities.readRecord.ReadRecordRepairReport
+import io.legado.app.data.entities.readRecord.ReadRecordTimeTotals
 import io.legado.app.data.local.preferences.LocalPreferencesKeys
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.first
@@ -135,11 +136,15 @@ class ReadRecordRepository(
     }
 
     suspend fun getMergeCandidates(targetRecord: ReadRecord): List<ReadRecord> {
-        return dao.getMergeCandidates(
-            targetRecord.deviceId,
-            targetRecord.bookName,
-            targetRecord.bookAuthor
-        )
+        return if (targetRecord.deviceId.isBlank()) {
+            dao.getMergeCandidatesAcrossDevices(targetRecord.bookName, targetRecord.bookAuthor)
+        } else {
+            dao.getMergeCandidates(
+                targetRecord.deviceId,
+                targetRecord.bookName,
+                targetRecord.bookAuthor
+            )
+        }
     }
 
     /** 获取指定书名下作者为空的旧记录,供打开书籍时确认归属。 */
@@ -278,13 +283,23 @@ class ReadRecordRepository(
                 }
                 .mapTo(linkedSetOf()) { it.deviceId }
                 .apply { addAll(dao.getReadRecordsByName(detail.bookName, detail.bookAuthor).map { it.deviceId }) }
+            // 汇总记录可能包含没有时段明细的旧版历史时长。删除前先扣除所有现存
+            // 时段,只保留这部分真正的历史时长;新版本纯由时段产生的记录应被删除。
+            val legacyReadTimes = affectedDevices.associateWith { deviceId ->
+                val record = dao.getReadRecord(deviceId, detail.bookName, detail.bookAuthor)
+                val sessionTime = dao.getSessionsByBook(deviceId, detail.bookName, detail.bookAuthor)
+                    .sumOf { it.endTime - it.startTime }
+                ((record?.readTime ?: 0L) - sessionTime).coerceAtLeast(0L)
+            }
             dao.deleteDetailByNameAndDate(detail.bookName, detail.bookAuthor, detail.date)
             affectedDevices.forEach { deviceId ->
                 dao.deleteSessionsByBookAndDate(deviceId, detail.bookName, detail.bookAuthor, detail.date)
-                // 没有阅读时段记录的旧版汇总记录不能因删除详情而被误删。
-                if (dao.getSessionsByBook(deviceId, detail.bookName, detail.bookAuthor).isNotEmpty()) {
-                    updateReadRecordTotal(deviceId, detail.bookName, detail.bookAuthor)
-                }
+                updateReadRecordTotal(
+                    deviceId,
+                    detail.bookName,
+                    detail.bookAuthor,
+                    legacyReadTimes[deviceId] ?: 0L,
+                )
             }
         }
     }
@@ -301,6 +316,20 @@ class ReadRecordRepository(
                 }
                 .mapTo(linkedSetOf()) { it.deviceId }
                 .apply { addAll(dao.getReadRecordsByName(session.bookName, session.bookAuthor).map { it.deviceId }) }
+            val legacyReadTimes = affectedDevices.associateWith { deviceId ->
+                val record = dao.getReadRecord(deviceId, session.bookName, session.bookAuthor)
+                val sessionTime = dao.getSessionsByBook(deviceId, session.bookName, session.bookAuthor)
+                    .sumOf { it.endTime - it.startTime }
+                ((record?.readTime ?: 0L) - sessionTime).coerceAtLeast(0L)
+            }
+            val daySessionsBeforeDelete = affectedDevices.associateWith { deviceId ->
+                dao.getSessionsByBookAndDate(
+                    deviceId,
+                    session.bookName,
+                    session.bookAuthor,
+                    session.startTime.toDateString(),
+                )
+            }
             dao.deleteSessionByIdentity(
                 session.bookName,
                 session.bookAuthor,
@@ -327,8 +356,26 @@ class ReadRecordRepository(
                         record.bookAuthor,
                         dateString,
                     )
+                    val legacyDetailTime = ((detail?.readTime ?: 0L) -
+                        daySessionsBeforeDelete[deviceId].orEmpty().sumOf { it.endTime - it.startTime }).coerceAtLeast(0L)
+                    val legacyDetailWords = ((detail?.readWords ?: 0L) -
+                        daySessionsBeforeDelete[deviceId].orEmpty().sumOf { it.words }).coerceAtLeast(0L)
                     if (remainingSessions.isEmpty()) {
-                        detail?.let { dao.deleteDetail(it) }
+                        if (legacyDetailTime <= 0L && legacyDetailWords <= 0L) {
+                            detail?.let { dao.deleteDetail(it) }
+                        } else {
+                            dao.insertDetail(
+                                (detail ?: ReadRecordDetail(
+                                    deviceId = record.deviceId,
+                                    bookName = record.bookName,
+                                    bookAuthor = record.bookAuthor,
+                                    date = dateString,
+                                )).copy(
+                                    readTime = legacyDetailTime,
+                                    readWords = legacyDetailWords,
+                                )
+                            )
+                        }
                     } else {
                         dao.insertDetail(
                             (detail ?: ReadRecordDetail(
@@ -337,8 +384,8 @@ class ReadRecordRepository(
                                 bookAuthor = record.bookAuthor,
                                 date = dateString,
                             )).copy(
-                                readTime = remainingSessions.sumOf { it.endTime - it.startTime },
-                                readWords = remainingSessions.sumOf { it.words },
+                                readTime = legacyDetailTime + remainingSessions.sumOf { it.endTime - it.startTime },
+                                readWords = legacyDetailWords + remainingSessions.sumOf { it.words },
                                 firstReadTime = remainingSessions
                                     .map { it.startTime }
                                     .filter { it > 0L }
@@ -347,7 +394,12 @@ class ReadRecordRepository(
                             )
                         )
                     }
-                    updateReadRecordTotal(deviceId, record.bookName, record.bookAuthor)
+                    updateReadRecordTotal(
+                        deviceId,
+                        record.bookName,
+                        record.bookAuthor,
+                        legacyReadTimes[deviceId] ?: 0L,
+                    )
                 }
         }
     }
@@ -358,16 +410,22 @@ class ReadRecordRepository(
         else -> min(left, right)
     }
 
-    private suspend fun updateReadRecordTotal(deviceId: String, bookName: String, bookAuthor: String) {
+    private suspend fun updateReadRecordTotal(
+        deviceId: String,
+        bookName: String,
+        bookAuthor: String,
+        legacyReadTime: Long = 0L,
+    ) {
         val allRemainingSessions = dao.getSessionsByBook(deviceId, bookName, bookAuthor)
+        val existingRecord = dao.getReadRecord(deviceId, bookName, bookAuthor)
+        val sessionTime = allRemainingSessions.sumOf { it.endTime - it.startTime }
 
-        if (allRemainingSessions.isEmpty()) {
-            dao.getReadRecord(deviceId, bookName, bookAuthor)?.let { dao.deleteReadRecord(it) }
+        if (allRemainingSessions.isEmpty() && legacyReadTime <= 0L) {
+            existingRecord?.let { dao.deleteReadRecord(it) }
         } else {
-            val totalTime = allRemainingSessions.sumOf { it.endTime - it.startTime }
-            val lastRead = allRemainingSessions.maxOf { it.endTime }
+            val totalTime = ReadRecordTimeTotals.total(sessionTime, legacyReadTime)
+            val lastRead = allRemainingSessions.maxOfOrNull { it.endTime } ?: existingRecord?.lastRead ?: 0L
 
-            val existingRecord = dao.getReadRecord(deviceId, bookName, bookAuthor)
             if (existingRecord == null) {
                 dao.insert(
                     ReadRecord(
@@ -405,104 +463,89 @@ class ReadRecordRepository(
         }
     }
 
-    suspend fun mergeReadRecordInto(targetRecord: ReadRecord, sourceRecords: List<ReadRecord>) {
-        database.withTransaction {
-            sourceRecords.forEach { sourceRecord ->
-                mergeSingleReadRecordInto(targetRecord, sourceRecord)
-            }
+    /** 用户主动合并独立阅读记录,累加有效时长并保留旧版历史时长。返回是否实际发生了合并。 */
+    suspend fun mergeIndependentReadRecordsInto(targetRecord: ReadRecord, sourceRecords: List<ReadRecord>): Boolean {
+        return database.withTransaction {
+            mergeIndependentReadRecords(targetRecord, sourceRecords)
         }
     }
 
-    private suspend fun mergeSingleReadRecordInto(targetRecord: ReadRecord, sourceRecord: ReadRecord) {
-        if (targetRecord == sourceRecord) return
-        if (targetRecord.deviceId != sourceRecord.deviceId) return
-
-        val source = dao.getReadRecord(
-            sourceRecord.deviceId,
-            sourceRecord.bookName,
-            sourceRecord.bookAuthor
-        ) ?: return
-
-        val target = dao.getReadRecord(
-            targetRecord.deviceId,
-            targetRecord.bookName,
-            targetRecord.bookAuthor
-        ) ?: targetRecord
-
-        dao.insert(
-            target.copy(
-                readTime = target.readTime + source.readTime,
-                lastRead = max(target.lastRead, source.lastRead)
-            )
-        )
+    private suspend fun mergeSingleReadRecordInto(targetRecord: ReadRecord, sourceRecord: ReadRecord) =
+        mergeIndependentReadRecords(targetRecord, listOf(sourceRecord))
 
-        val sourceDetails = dao.getDetailsByBook(
-            sourceRecord.deviceId,
-            sourceRecord.bookName,
-            sourceRecord.bookAuthor
-        )
-        sourceDetails.forEach { detail ->
-            val existingTargetDetail = dao.getDetail(
-                targetRecord.deviceId,
-                targetRecord.bookName,
-                targetRecord.bookAuthor,
-                detail.date
-            )
-            if (existingTargetDetail == null) {
-                dao.insertDetail(
-                    detail.copy(
-                        bookName = targetRecord.bookName,
-                        bookAuthor = targetRecord.bookAuthor
-                    )
-                )
-            } else {
-                dao.insertDetail(
-                    existingTargetDetail.copy(
-                        readTime = existingTargetDetail.readTime + detail.readTime,
-                        readWords = existingTargetDetail.readWords + detail.readWords,
-                        firstReadTime = minPositive(existingTargetDetail.firstReadTime, detail.firstReadTime),
-                        lastReadTime = max(existingTargetDetail.lastReadTime, detail.lastReadTime)
-                    )
-                )
-            }
+    private suspend fun mergeIndependentReadRecords(targetRecord: ReadRecord, sourceRecords: List<ReadRecord>): Boolean {
+        val resolvedTarget = if (targetRecord.deviceId.isBlank()) {
+            dao.getReadRecordsByName(targetRecord.bookName, targetRecord.bookAuthor).maxByOrNull { it.lastRead }
+        } else {
+            dao.getReadRecord(targetRecord.deviceId, targetRecord.bookName, targetRecord.bookAuthor)
+        }
+        val targetDeviceId = resolvedTarget?.deviceId ?: targetRecord.deviceId.ifBlank { sourceRecords.firstOrNull()?.deviceId ?: return false }
+        val target = resolvedTarget ?: targetRecord.copy(deviceId = targetDeviceId, readTime = 0L)
+        val sources = sourceRecords.mapNotNull { source ->
+            dao.getReadRecord(source.deviceId, source.bookName, source.bookAuthor)
+        }.distinctBy { Triple(it.deviceId, it.bookName, it.bookAuthor) }
+            .filterNot { it.deviceId == targetDeviceId && it.bookName == target.bookName && it.bookAuthor == target.bookAuthor }
+        if (sources.isEmpty()) return false
+
+        val targetSessions = dao.getSessionsByBook(targetDeviceId, target.bookName, target.bookAuthor)
+        val targetDetails = dao.getDetailsByBook(targetDeviceId, target.bookName, target.bookAuthor)
+        val sourceSessions = sources.associateWith { dao.getSessionsByBook(it.deviceId, it.bookName, it.bookAuthor) }
+        val sourceDetails = sources.associateWith { dao.getDetailsByBook(it.deviceId, it.bookName, it.bookAuthor) }
+        val targetLegacy = ReadRecordTimeTotals.legacy(target.readTime, targetSessions.sumOf { it.endTime - it.startTime })
+        val sourceLegacy = sources.sumOf { source ->
+            ReadRecordTimeTotals.legacy(source.readTime, sourceSessions.getValue(source).sumOf { it.endTime - it.startTime })
         }
-        dao.deleteDetailsByBook(sourceRecord.deviceId, sourceRecord.bookName, sourceRecord.bookAuthor)
 
-        val sourceSessions = dao.getSessionsByBook(
-            sourceRecord.deviceId,
-            sourceRecord.bookName,
-            sourceRecord.bookAuthor
-        )
-        sourceSessions.forEach { session ->
-            dao.updateSession(
-                session.copy(
-                    bookName = targetRecord.bookName,
-                    bookAuthor = targetRecord.bookAuthor
-                )
-            )
+        rebuildMergedDetails(target, targetDetails, targetSessions, sourceDetails, sourceSessions)
+        sources.forEach { source ->
+            sourceSessions.getValue(source).forEach { session ->
+                dao.updateSession(session.copy(deviceId = targetDeviceId, bookName = target.bookName, bookAuthor = target.bookAuthor))
+            }
+            dao.deleteReadRecord(source)
         }
+        dao.deleteDuplicateSessionsByBook(targetDeviceId, target.bookName, target.bookAuthor)
+        dao.insert(target.copy(lastRead = maxOf(target.lastRead, sources.maxOf { it.lastRead })))
+        updateReadRecordTotal(targetDeviceId, target.bookName, target.bookAuthor, targetLegacy + sourceLegacy)
+        return true
+    }
 
-        dao.deleteReadRecord(source)
-        updateReadRecordTotal(targetRecord.deviceId, targetRecord.bookName, targetRecord.bookAuthor)
-        // 旧版只保存汇总时长、没有阅读时段明细;重算阅读时段总时长时不能把这部分历史清零。
-        if (sourceSessions.isEmpty() && source.readTime > 0) {
-            val mergedTarget = dao.getReadRecord(
-                targetRecord.deviceId,
-                targetRecord.bookName,
-                targetRecord.bookAuthor
-            )
-            if (mergedTarget == null) {
-                dao.insert(
-                    ReadRecord(
-                        deviceId = targetRecord.deviceId,
-                        bookName = targetRecord.bookName,
-                        bookAuthor = targetRecord.bookAuthor,
-                        readTime = source.readTime,
-                        lastRead = max(target.lastRead, source.lastRead)
-                    )
-                )
-            } else {
-                dao.update(mergedTarget.copy(readTime = mergedTarget.readTime + source.readTime))
+    private suspend fun rebuildMergedDetails(
+        target: ReadRecord,
+        targetDetails: List<ReadRecordDetail>,
+        targetSessions: List<ReadRecordSession>,
+        sourceDetails: Map<ReadRecord, List<ReadRecordDetail>>,
+        sourceSessions: Map<ReadRecord, List<ReadRecordSession>>,
+    ) {
+        val allDetails = targetDetails + sourceDetails.values.flatten()
+        val originalSessions = targetSessions + sourceSessions.values.flatten()
+        val sessionsByRecord = originalSessions.groupBy { Triple(it.deviceId, it.bookName, it.bookAuthor) }
+        val sessionsByDate = originalSessions
+            .map { it.copy(deviceId = target.deviceId, bookName = target.bookName, bookAuthor = target.bookAuthor) }
+            .distinctBy { listOf(it.bookName, it.bookAuthor, it.startTime, it.endTime, it.words) }
+            .groupBy { it.startTime.toDateString() }
+        val detailsByDate = allDetails.groupBy { it.date }
+        dao.deleteDetailsByBook(target.deviceId, target.bookName, target.bookAuthor)
+        sourceDetails.keys.forEach { dao.deleteDetailsByBook(it.deviceId, it.bookName, it.bookAuthor) }
+        (sessionsByDate.keys + detailsByDate.keys).forEach { date ->
+            val sessions = sessionsByDate[date].orEmpty()
+            val details = detailsByDate[date].orEmpty()
+            val legacyTime = details.sumOf { detail ->
+                ReadRecordTimeTotals.legacy(detail.readTime, sessionsByRecord[Triple(detail.deviceId, detail.bookName, detail.bookAuthor)]
+                    .orEmpty().filter { it.startTime.toDateString() == date }.sumOf { it.endTime - it.startTime })
+            }
+            val legacyWords = details.sumOf { detail ->
+                (detail.readWords - sessionsByRecord[Triple(detail.deviceId, detail.bookName, detail.bookAuthor)]
+                    .orEmpty().filter { it.startTime.toDateString() == date }.sumOf { it.words }).coerceAtLeast(0L)
+            }
+            val sessionTime = sessions.sumOf { it.endTime - it.startTime }
+            val sessionWords = sessions.sumOf { it.words }
+            if (sessionTime + legacyTime > 0L || sessionWords + legacyWords > 0L) {
+                dao.insertDetail(ReadRecordDetail(
+                    deviceId = target.deviceId, bookName = target.bookName, bookAuthor = target.bookAuthor, date = date,
+                    readTime = sessionTime + legacyTime, readWords = sessionWords + legacyWords,
+                    firstReadTime = details.map { it.firstReadTime }.filter { it > 0L }.plus(sessions.map { it.startTime }.filter { it > 0L }).minOrNull() ?: 0L,
+                    lastReadTime = maxOf(details.maxOfOrNull { it.lastReadTime } ?: 0L, sessions.maxOfOrNull { it.endTime } ?: 0L),
+                ))
             }
         }
     }
@@ -510,14 +553,75 @@ class ReadRecordRepository(
     /** 清理字段完全相同的阅读时段记录,并根据剩余记录重建汇总记录。 */
     suspend fun repairDuplicateSessions(): Int {
         return database.withTransaction {
-            val before = dao.allSession.size
+            val sessionsBefore = dao.allSession
+            val recordsBefore = dao.all.associateBy { Triple(it.deviceId, it.bookName, it.bookAuthor) }
+            val detailsBefore = dao.allDetail.groupBy { Triple(it.deviceId, it.bookName, it.bookAuthor) }
+            val affectedKeys = sessionsBefore
+                .map { Triple(it.deviceId, it.bookName, it.bookAuthor) }
+                .toSet()
             dao.deleteDuplicateSessions()
-            dao.all.forEach { record ->
-                if (dao.getSessionsByBook(record.deviceId, record.bookName, record.bookAuthor).isNotEmpty()) {
-                    updateReadRecordTotal(record.deviceId, record.bookName, record.bookAuthor)
+            affectedKeys.forEach { (deviceId, bookName, bookAuthor) ->
+                val oldSessions = sessionsBefore.filter {
+                    it.deviceId == deviceId && it.bookName == bookName && it.bookAuthor == bookAuthor
                 }
+                val legacyReadTime = recordsBefore[Triple(deviceId, bookName, bookAuthor)]?.let {
+                    ReadRecordTimeTotals.legacy(it.readTime, oldSessions.sumOf { session -> session.endTime - session.startTime })
+                } ?: 0L
+                updateReadRecordTotal(deviceId, bookName, bookAuthor, legacyReadTime)
+                rebuildDetailsAfterSessionRepair(
+                    deviceId,
+                    bookName,
+                    bookAuthor,
+                    oldSessions,
+                    detailsBefore[Triple(deviceId, bookName, bookAuthor)].orEmpty(),
+                )
+            }
+            return@withTransaction sessionsBefore.size - dao.allSession.size
+        }
+    }
+
+    private suspend fun rebuildDetailsAfterSessionRepair(
+        deviceId: String,
+        bookName: String,
+        bookAuthor: String,
+        oldSessions: List<ReadRecordSession>,
+        oldDetails: List<ReadRecordDetail>,
+    ) {
+        val newSessions = dao.getSessionsByBook(deviceId, bookName, bookAuthor)
+        val oldSessionsByDate = oldSessions.groupBy { it.startTime.toDateString() }
+        val newSessionsByDate = newSessions.groupBy { it.startTime.toDateString() }
+        val detailsByDate = oldDetails.associateBy { it.date }
+        val dates = (oldSessionsByDate.keys + newSessionsByDate.keys + detailsByDate.keys)
+        dates.forEach { date ->
+            val oldDetail = detailsByDate[date]
+            val oldSessionTime = oldSessionsByDate[date].orEmpty().sumOf { it.endTime - it.startTime }
+            val oldSessionWords = oldSessionsByDate[date].orEmpty().sumOf { it.words }
+            val legacyTime = oldDetail?.let { (it.readTime - oldSessionTime).coerceAtLeast(0L) } ?: 0L
+            val legacyWords = oldDetail?.let { (it.readWords - oldSessionWords).coerceAtLeast(0L) } ?: 0L
+            val sessions = newSessionsByDate[date].orEmpty()
+            val readTime = sessions.sumOf { it.endTime - it.startTime } + legacyTime
+            val readWords = sessions.sumOf { it.words } + legacyWords
+            if (readTime <= 0L && readWords <= 0L) {
+                oldDetail?.let { dao.deleteDetail(it) }
+            } else {
+                dao.insertDetail((oldDetail ?: ReadRecordDetail(
+                    deviceId = deviceId,
+                    bookName = bookName,
+                    bookAuthor = bookAuthor,
+                    date = date,
+                )).copy(
+                    readTime = readTime,
+                    readWords = readWords,
+                    firstReadTime = listOfNotNull(
+                        oldDetail?.firstReadTime?.takeIf { it > 0L },
+                        sessions.map { it.startTime }.filter { it > 0L }.minOrNull(),
+                    ).minOrNull() ?: 0L,
+                    lastReadTime = maxOf(
+                        oldDetail?.lastReadTime ?: 0L,
+                        sessions.maxOfOrNull { it.endTime } ?: 0L,
+                    ),
+                ))
             }
-            return@withTransaction before - dao.allSession.size
         }
     }
 
@@ -532,7 +636,7 @@ class ReadRecordRepository(
                 val author = ReadRecordIdentity.author(record.bookAuthor)
                 if (name != record.bookName || author != record.bookAuthor) {
                     runCatching {
-                        mergeReadRecordInto(ReadRecord(record.deviceId, name, author), listOf(record))
+                        mergeIndependentReadRecordsInto(ReadRecord(record.deviceId, name, author), listOf(record))
                         merged++
                         normalized++
                     }.onFailure { exceptions++ }
@@ -606,13 +710,14 @@ class ReadRecordRepository(
         )
     }
 
-    /**
+    /*
      * 以阅读时段记录为权威重建汇总与每日明细,并保留未被会话覆盖的历史时长。
      *
      * 备份恢复后调用:会话按身份去重导入(幂等),汇总/明细导入取已有与导入两者中的较大值,
      * 再按会话重算,可避免同一备份重复导入导致时长翻倍,同时正确合并跨设备的会话时长。
      */
-    suspend fun reconcileReadRecordTotalsFromSessions() {
+    /** 备份恢复后按取大值原则重算汇总,保证重复恢复幂等。 */
+    suspend fun reconcileRestoredReadRecordTotals() {
         database.withTransaction {
             dao.all.forEach { record ->
                 val sessions = dao.getSessionsByBook(record.deviceId, record.bookName, record.bookAuthor)

+ 2 - 2
app/src/main/java/io/legado/app/help/storage/Restore.kt

@@ -288,7 +288,7 @@ object Restore : KoinComponent {
             reconcileReadRecordAliases()
             // 会话导入按身份去重(幂等),汇总/明细取较大值后按会话重算,
             // 避免同一备份重复导入导致阅读时长翻倍。
-            get<ReadRecordRepository>().reconcileReadRecordTotalsFromSessions()
+            get<ReadRecordRepository>().reconcileRestoredReadRecordTotals()
         }
         if (BackupConfig.dbIsNotIgnored("server")) {
             File(path, "servers.json").takeIf {
@@ -491,7 +491,7 @@ object Restore : KoinComponent {
                     .distinct()
                     .toList()
                 val author = authors.singleOrNull() ?: return@forEach
-                repository.mergeReadRecordInto(
+                repository.mergeIndependentReadRecordsInto(
                     targetRecord = ReadRecord(
                         deviceId = source.deviceId,
                         bookName = source.bookName,

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadRecordAliasDelegate.kt

@@ -90,7 +90,7 @@ class ReadRecordAliasDelegate(
 
     private suspend fun merge(book: Book, sources: List<ReadRecord>) {
         sources.groupBy { it.deviceId }.forEach { (deviceId, records) ->
-            readRecordRepository.mergeReadRecordInto(
+            readRecordRepository.mergeIndependentReadRecordsInto(
                 ReadRecord(deviceId, book.name, book.author),
                 records,
             )

+ 49 - 16
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt

@@ -44,6 +44,7 @@ import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.produceState
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
@@ -82,6 +83,7 @@ import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.checkBox.CheckboxItem
+import io.legado.app.ui.widget.components.checkBox.AppCheckbox
 import io.legado.app.ui.widget.components.heatmap.HeatmapCalendarEndAction
 import io.legado.app.ui.widget.components.heatmap.HeatmapCalendarStartAction
 import io.legado.app.ui.widget.components.heatmap.HeatmapConfig
@@ -95,6 +97,7 @@ import io.legado.app.ui.widget.components.heatmap.rememberDateRange
 import io.legado.app.ui.widget.components.heatmap.rememberDaysInRange
 import io.legado.app.ui.widget.components.heatmap.rememberWeeks
 import io.legado.app.ui.widget.components.image.cover.CoilBookCover
+import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
 import io.legado.app.ui.widget.components.list.TopFloatingStickyItem
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.settingItem.CompactClickableSettingItem
@@ -577,6 +580,7 @@ fun ReadRecordScreen(
             } ?: emptySet()
         )
     }
+    var mergeCandidateQuery by rememberSaveable(mergeDialogData != null) { mutableStateOf("") }
 
     AppAlertDialog(
         data = mergeDialogData,
@@ -594,28 +598,57 @@ fun ReadRecordScreen(
                 )
                 Spacer(modifier = Modifier.height(8.dp))
 
-                LazyColumn(modifier = Modifier.height(320.dp)) {
-                    items(candidates, key = { it.mergeKey() }) { candidate ->
+                SearchBar(
+                    query = mergeCandidateQuery,
+                    onQueryChange = { mergeCandidateQuery = it },
+                    autoFocus = false,
+                    placeholder = stringResource(R.string.search_placeholder),
+                )
+                Spacer(modifier = Modifier.height(8.dp))
+
+                FastScrollLazyColumn(modifier = Modifier.height(320.dp)) {
+                    val filteredCandidates = candidates.filter { candidate ->
+                        mergeCandidateQuery.isBlank() ||
+                            candidate.bookName.contains(mergeCandidateQuery, ignoreCase = true) ||
+                            candidate.bookAuthor.contains(mergeCandidateQuery, ignoreCase = true)
+                    }
+                    items(filteredCandidates, key = { it.mergeKey() }) { candidate ->
                         val author = candidate.bookAuthor.ifBlank { unknownAuthor }
                         val candidateKey = candidate.mergeKey()
                         val isChecked = selectedMergeKeys.contains(candidateKey)
 
-                        CheckboxItem(
-                            title = stringResource(
-                                R.string.merge_read_record_candidate,
-                                candidate.bookName,
-                                author,
-                                formatDuring(candidate.readTime)
-                            ),
-                            checked = isChecked,
-                            onCheckedChange = { checked ->
-                                selectedMergeKeys = if (checked) {
-                                    selectedMergeKeys + candidateKey
-                                } else {
-                                    selectedMergeKeys - candidateKey
+                        GlassCard(
+                            modifier = Modifier
+                                .fillMaxWidth()
+                                .padding(vertical = 4.dp),
+                        ) {
+                            Box(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
+                                Column(modifier = Modifier.fillMaxWidth().padding(end = 48.dp)) {
+                                    AppText(text = candidate.bookName)
+                                    AppText(
+                                        text = author,
+                                        style = LegadoTheme.typography.bodySmall,
+                                        color = LegadoTheme.colorScheme.onSurfaceVariant,
+                                    )
+                                    AppText(
+                                        text = formatDuring(candidate.readTime),
+                                        style = LegadoTheme.typography.bodySmall,
+                                        color = LegadoTheme.colorScheme.onSurfaceVariant,
+                                    )
                                 }
+                                AppCheckbox(
+                                    checked = isChecked,
+                                    onCheckedChange = { checked ->
+                                        selectedMergeKeys = if (checked) {
+                                            selectedMergeKeys + candidateKey
+                                        } else {
+                                            selectedMergeKeys - candidateKey
+                                        }
+                                    },
+                                    modifier = Modifier.align(Alignment.CenterEnd),
+                                )
                             }
-                        )
+                        }
                     }
                 }
             }

+ 8 - 2
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt

@@ -279,9 +279,15 @@ class ReadRecordViewModel(
     }
 
     fun mergeReadRecords(targetRecord: ReadRecord, sourceRecords: List<ReadRecord>) {
-        if (sourceRecords.isEmpty()) return
+        if (sourceRecords.isEmpty()) {
+            _effects.tryEmit(ReadRecordEffect.ShowError(""))
+            return
+        }
         viewModelScope.launch {
-            repository.mergeReadRecordInto(targetRecord, sourceRecords)
+            val merged = repository.mergeIndependentReadRecordsInto(targetRecord, sourceRecords)
+            if (!merged) {
+                _effects.tryEmit(ReadRecordEffect.ShowError(""))
+            }
         }
     }
 

+ 35 - 0
app/src/test/java/io/legado/app/data/entities/readRecord/ReadRecordTimeTotalsTest.kt

@@ -0,0 +1,35 @@
+package io.legado.app.data.entities.readRecord
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class ReadRecordTimeTotalsTest {
+    @Test
+    fun `sessions from target and source are accumulated`() {
+        assertEquals(300L, merge(targetTotal = 100, targetSessions = 100, sourceTotal = 200, sourceSessions = 200))
+    }
+
+    @Test
+    fun `source legacy history remains when target only has sessions`() {
+        assertEquals(300L, merge(targetTotal = 100, targetSessions = 100, sourceTotal = 200, sourceSessions = 0))
+    }
+
+    @Test
+    fun `target legacy history remains when source only has sessions`() {
+        assertEquals(300L, merge(targetTotal = 100, targetSessions = 0, sourceTotal = 200, sourceSessions = 200))
+    }
+
+    @Test
+    fun `legacy-only result is stable when calculated again`() {
+        val merged = merge(targetTotal = 100, targetSessions = 0, sourceTotal = 200, sourceSessions = 0)
+        assertEquals(300L, merged)
+        assertEquals(merged, ReadRecordTimeTotals.total(0, ReadRecordTimeTotals.legacy(merged, 0)))
+    }
+
+    private fun merge(targetTotal: Long, targetSessions: Long, sourceTotal: Long, sourceSessions: Long): Long =
+        ReadRecordTimeTotals.total(
+            targetSessions + sourceSessions,
+            ReadRecordTimeTotals.legacy(targetTotal, targetSessions) +
+                ReadRecordTimeTotals.legacy(sourceTotal, sourceSessions),
+        )
+}

+ 235 - 0
app/src/test/java/io/legado/app/data/repository/ReadRecordRepositoryTest.kt

@@ -0,0 +1,235 @@
+package io.legado.app.data.repository
+
+import android.app.Application
+import androidx.room.Room
+import io.legado.app.data.AppDatabase
+import io.legado.app.data.entities.readRecord.ReadRecord
+import io.legado.app.data.entities.readRecord.ReadRecordDetail
+import io.legado.app.data.entities.readRecord.ReadRecordSession
+import io.legado.app.help.config.AppConfigStore
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [35])
+class ReadRecordRepositoryTest {
+    private lateinit var database: AppDatabase
+    private lateinit var repository: ReadRecordRepository
+
+    @Before
+    fun setUp() {
+        // ReadRecordRepository 构造即求值 readRecordEnabled,会访问 AppConfigStore;
+        // 生产环境由 App.onCreate 首行 init() 完成,测试里在 Robolectric 应用上下文中补上。
+        AppConfigStore.init(RuntimeEnvironment.getApplication())
+        database = Room.inMemoryDatabaseBuilder(RuntimeEnvironment.getApplication(), AppDatabase::class.java)
+            .allowMainThreadQueries().build()
+        repository = ReadRecordRepository(database.readRecordDao, database, SettingsRepository())
+    }
+
+    @After
+    fun tearDown() = database.close()
+
+    @Test
+    fun `merge accumulates target and source sessions`() = runBlocking {
+        mergeAndAssert(targetSessionDuration = 100, sourceSessionDuration = 200)
+    }
+
+    @Test
+    fun `merge keeps source legacy duration`() = runBlocking {
+        mergeAndAssert(targetSessionDuration = 100, sourceLegacyTime = 200)
+    }
+
+    @Test
+    fun `merge keeps target legacy duration`() = runBlocking {
+        mergeAndAssert(targetLegacyTime = 100, sourceSessionDuration = 200)
+    }
+
+    @Test
+    fun `repeating an independent merge does not add time again`() = runBlocking {
+        val source = insertRecord(sourceName, 200)
+        insertRecord(targetName, 100)
+
+        repository.mergeIndependentReadRecordsInto(targetRecord(), listOf(source))
+        repository.mergeIndependentReadRecordsInto(targetRecord(), listOf(source))
+
+        assertEquals(300L, targetReadTime())
+        assertNull(database.readRecordDao.getReadRecord(deviceId, sourceName, author))
+    }
+
+    @Test
+    fun `cross device merge moves sessions and removes source records`() = runBlocking {
+        val targetDevice = "target-device"
+        val sourceDevice = "source-device"
+        database.readRecordDao.insert(ReadRecord(targetDevice, targetName, author, 100, 2_000))
+        val source = ReadRecord(sourceDevice, sourceName, author, 200, 1_000)
+        database.readRecordDao.insert(source)
+        database.readRecordDao.insertSession(ReadRecordSession(deviceId = sourceDevice, bookName = sourceName, bookAuthor = author, startTime = 3_000, endTime = 3_200, words = 10))
+
+        repository.mergeIndependentReadRecordsInto(ReadRecord("", targetName, author), listOf(source))
+
+        assertEquals(300L, database.readRecordDao.getReadRecord(targetDevice, targetName, author)?.readTime)
+        assertEquals(1, database.readRecordDao.getSessionsByBook(targetDevice, targetName, author).size)
+        assertNull(database.readRecordDao.getReadRecord(sourceDevice, sourceName, author))
+    }
+
+    @Test
+    fun `synchronized session copies are only counted once in details`() = runBlocking {
+        val targetDevice = "target-device"
+        val sourceDevice = "source-device"
+        val date = "1970-01-01"
+        database.readRecordDao.insert(ReadRecord(targetDevice, targetName, author, 200, 2_000))
+        database.readRecordDao.insert(ReadRecord(sourceDevice, sourceName, author, 200, 1_000))
+        database.readRecordDao.insertSession(ReadRecordSession(deviceId = targetDevice, bookName = targetName, bookAuthor = author, startTime = 3_000, endTime = 3_200, words = 10))
+        database.readRecordDao.insertSession(ReadRecordSession(deviceId = sourceDevice, bookName = sourceName, bookAuthor = author, startTime = 3_000, endTime = 3_200, words = 10))
+        database.readRecordDao.insertDetail(ReadRecordDetail(targetDevice, targetName, author, date, 200, 10, 3_000, 3_200))
+        database.readRecordDao.insertDetail(ReadRecordDetail(sourceDevice, sourceName, author, date, 200, 10, 3_000, 3_200))
+
+        repository.mergeIndependentReadRecordsInto(ReadRecord("", targetName, author), listOf(ReadRecord(sourceDevice, sourceName, author)))
+
+        val detail = database.readRecordDao.getDetail(targetDevice, targetName, author, date)
+        assertEquals(200L, detail?.readTime)
+        assertEquals(10L, detail?.readWords)
+        assertEquals(1, database.readRecordDao.getSessionsByBook(targetDevice, targetName, author).size)
+        assertNull(database.readRecordDao.getDetail(sourceDevice, sourceName, author, date))
+    }
+
+    @Test
+    fun `repair duplicate sessions removes orphaned duplicate totals`() = runBlocking {
+        val targetDevice = "target-device"
+        val sourceDevice = "source-device"
+        val date = "1970-01-01"
+        val session = ReadRecordSession(
+            deviceId = targetDevice,
+            bookName = targetName,
+            bookAuthor = author,
+            startTime = 3_000,
+            endTime = 3_200,
+            words = 10,
+        )
+        database.readRecordDao.insert(ReadRecord(targetDevice, targetName, author, 200, 2_000))
+        database.readRecordDao.insert(ReadRecord(sourceDevice, targetName, author, 200, 2_000))
+        database.readRecordDao.insertSession(session)
+        database.readRecordDao.insertSession(session.copy(deviceId = sourceDevice))
+        database.readRecordDao.insertDetail(ReadRecordDetail(targetDevice, targetName, author, date, 200, 10, 3_000, 3_200))
+        database.readRecordDao.insertDetail(ReadRecordDetail(sourceDevice, targetName, author, date, 200, 10, 3_000, 3_200))
+
+        assertEquals(1, repository.repairDuplicateSessions())
+
+        assertEquals(200L, database.readRecordDao.getReadRecord(targetDevice, targetName, author)?.readTime)
+        assertNull(database.readRecordDao.getReadRecord(sourceDevice, targetName, author))
+        assertEquals(200L, database.readRecordDao.getDetail(targetDevice, targetName, author, date)?.readTime)
+        assertNull(database.readRecordDao.getDetail(sourceDevice, targetName, author, date))
+    }
+
+    @Test
+    fun `repair duplicate sessions keeps legacy duration on orphaned device`() = runBlocking {
+        val targetDevice = "target-device"
+        val sourceDevice = "source-device"
+        val date = "1970-01-01"
+        val session = ReadRecordSession(
+            deviceId = targetDevice,
+            bookName = targetName,
+            bookAuthor = author,
+            startTime = 3_000,
+            endTime = 3_200,
+            words = 10,
+        )
+        // sourceDevice 的汇总时长 = 副本 session 200 + 旧版历史 legacy 100,
+        // 修复去重后 session 副本被保留在 targetDevice,sourceDevice 应只剩 legacy。
+        database.readRecordDao.insert(ReadRecord(targetDevice, targetName, author, 200, 2_000))
+        database.readRecordDao.insert(ReadRecord(sourceDevice, targetName, author, 300, 2_000))
+        database.readRecordDao.insertSession(session)
+        database.readRecordDao.insertSession(session.copy(deviceId = sourceDevice))
+        database.readRecordDao.insertDetail(ReadRecordDetail(targetDevice, targetName, author, date, 200, 10, 3_000, 3_200))
+        database.readRecordDao.insertDetail(ReadRecordDetail(sourceDevice, targetName, author, date, 300, 10, 3_000, 3_200))
+
+        assertEquals(1, repository.repairDuplicateSessions())
+
+        assertEquals(200L, database.readRecordDao.getReadRecord(targetDevice, targetName, author)?.readTime)
+        assertEquals(100L, database.readRecordDao.getReadRecord(sourceDevice, targetName, author)?.readTime)
+        assertEquals(200L, database.readRecordDao.getDetail(targetDevice, targetName, author, date)?.readTime)
+        assertEquals(100L, database.readRecordDao.getDetail(sourceDevice, targetName, author, date)?.readTime)
+    }
+
+    @Test
+    fun `deleting the last aggregate detail removes session-backed total`() = runBlocking {
+        val date = "1970-01-01"
+        val record = ReadRecord(deviceId, targetName, author, 200, 1_200)
+        val session = ReadRecordSession(
+            deviceId = deviceId,
+            bookName = targetName,
+            bookAuthor = author,
+            startTime = 1_000,
+            endTime = 1_200,
+            words = 10,
+        )
+        database.readRecordDao.insert(record)
+        database.readRecordDao.insertSession(session)
+        database.readRecordDao.insertDetail(ReadRecordDetail(deviceId, targetName, author, date, 200, 10, 1_000, 1_200))
+
+        repository.deleteDetail(database.readRecordDao.getDetail(deviceId, targetName, author, date)!!)
+
+        assertNull(database.readRecordDao.getReadRecord(deviceId, targetName, author))
+        assertNull(database.readRecordDao.getDetail(deviceId, targetName, author, date))
+        assertEquals(0, database.readRecordDao.getSessionsByBook(deviceId, targetName, author).size)
+    }
+
+    @Test
+    fun `deleting the last session preserves legacy total only`() = runBlocking {
+        val date = "1970-01-01"
+        val record = ReadRecord(deviceId, targetName, author, 300, 1_200)
+        val session = ReadRecordSession(
+            deviceId = deviceId,
+            bookName = targetName,
+            bookAuthor = author,
+            startTime = 1_000,
+            endTime = 1_200,
+            words = 10,
+        )
+        database.readRecordDao.insert(record)
+        database.readRecordDao.insertSession(session)
+        database.readRecordDao.insertDetail(ReadRecordDetail(deviceId, targetName, author, date, 200, 10, 1_000, 1_200))
+
+        repository.deleteSession(session)
+
+        assertEquals(100L, database.readRecordDao.getReadRecord(deviceId, targetName, author)?.readTime)
+        assertNull(database.readRecordDao.getDetail(deviceId, targetName, author, date))
+        assertEquals(0, database.readRecordDao.getSessionsByBook(deviceId, targetName, author).size)
+    }
+
+    private suspend fun mergeAndAssert(targetSessionDuration: Long = 0, sourceSessionDuration: Long = 0, targetLegacyTime: Long = 0, sourceLegacyTime: Long = 0) {
+        val source = insertRecord(sourceName, sourceSessionDuration + sourceLegacyTime, sourceSessionDuration)
+        insertRecord(targetName, targetSessionDuration + targetLegacyTime, targetSessionDuration)
+        repository.mergeIndependentReadRecordsInto(targetRecord(), listOf(source))
+        assertEquals(300L, targetReadTime())
+        assertNull(database.readRecordDao.getReadRecord(deviceId, sourceName, author))
+    }
+
+    private suspend fun insertRecord(name: String, readTime: Long, sessionDuration: Long = 0): ReadRecord {
+        val record = ReadRecord(deviceId, name, author, readTime, 1_000)
+        database.readRecordDao.insert(record)
+        if (sessionDuration > 0) {
+            val start = if (name == sourceName) 2_000L else 1_000L
+            database.readRecordDao.insertSession(ReadRecordSession(deviceId = deviceId, bookName = name, bookAuthor = author, startTime = start, endTime = start + sessionDuration, words = 10))
+        }
+        return record
+    }
+
+    private suspend fun targetReadTime() = database.readRecordDao.getReadRecord(deviceId, targetName, author)?.readTime ?: -1
+    private fun targetRecord() = ReadRecord(deviceId, targetName, author)
+
+    private companion object {
+        const val deviceId = "device"
+        const val author = "author"
+        const val targetName = "target"
+        const val sourceName = "source"
+    }
+}