Sfoglia il codice sorgente

fix(read-record): 修复跨午夜阅读时长统计

- 按本地时区午夜拆分跨自然日阅读会话的每日时长
- 保留完整阅读会话,避免为统计拆分底层会话记录
- 修复跨午夜会话删除后两天阅读详情未同步清理的问题
- 更新合并、修复和汇总逻辑,正确计算跨日时长与字数
- 补充跨午夜保存、统计和删除场景测试
coastline-echo 1 mese fa
parent
commit
214a58e44f

+ 107 - 80
app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt

@@ -25,6 +25,7 @@ import kotlinx.coroutines.withContext
 import kotlinx.datetime.Instant
 import kotlinx.datetime.Instant
 import kotlinx.datetime.TimeZone
 import kotlinx.datetime.TimeZone
 import kotlinx.datetime.toLocalDateTime
 import kotlinx.datetime.toLocalDateTime
+import java.time.ZoneId
 import kotlin.math.max
 import kotlin.math.max
 import kotlin.math.min
 import kotlin.math.min
 
 
@@ -38,6 +39,31 @@ class ReadRecordRepository(
     private fun Long.toDateString(): String =
     private fun Long.toDateString(): String =
         Instant.fromEpochMilliseconds(this).toLocalDateTime(TimeZone.currentSystemDefault()).date.toString()
         Instant.fromEpochMilliseconds(this).toLocalDateTime(TimeZone.currentSystemDefault()).date.toString()
 
 
+    /** 将跨自然日的阅读会话按本地时区午夜拆分,时间线仍保留原始整段会话。 */
+    private fun ReadRecordSession.durationByDate(): Map<String, Long> {
+        if (endTime <= startTime) return emptyMap()
+        val zone = ZoneId.systemDefault()
+        var cursor = startTime
+        val result = linkedMapOf<String, Long>()
+        while (cursor < endTime) {
+            val date = java.time.Instant.ofEpochMilli(cursor).atZone(zone).toLocalDate()
+            val nextMidnight = date.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
+            val segmentEnd = minOf(endTime, nextMidnight)
+            result[date.toString()] = (result[date.toString()] ?: 0L) + (segmentEnd - cursor)
+            cursor = segmentEnd
+        }
+        return result
+    }
+
+    private fun List<ReadRecordSession>.durationByDate(): Map<String, Long> =
+        flatMap { session -> session.durationByDate().entries }
+            .groupingBy { it.key }
+            .fold(0L) { total, entry -> total + entry.value }
+
+    private fun List<ReadRecordSession>.wordsByStartDate(): Map<String, Long> =
+        groupBy { it.startTime.toDateString() }
+            .mapValues { (_, sessions) -> sessions.sumOf { it.words } }
+
     val readRecordEnabled: Flow<Boolean> =
     val readRecordEnabled: Flow<Boolean> =
         localPreferencesRepository.getPreference(LocalPreferencesKeys.ENABLE_READ_RECORD, true)
         localPreferencesRepository.getPreference(LocalPreferencesKeys.ENABLE_READ_RECORD, true)
 
 
@@ -362,8 +388,14 @@ class ReadRecordRepository(
 
 
             val segmentDuration = normalizedSession.endTime - normalizedSession.startTime
             val segmentDuration = normalizedSession.endTime - normalizedSession.startTime
             dao.insertSession(normalizedSession)
             dao.insertSession(normalizedSession)
-            val dateString = normalizedSession.startTime.toDateString()
-            updateReadRecordDetail(normalizedSession, segmentDuration, normalizedSession.words, dateString)
+            normalizedSession.durationByDate().forEach { (date, duration) ->
+                updateReadRecordDetail(
+                    normalizedSession,
+                    duration,
+                    if (date == normalizedSession.startTime.toDateString()) normalizedSession.words else 0L,
+                    date,
+                )
+            }
             updateReadRecord(normalizedSession, segmentDuration)
             updateReadRecord(normalizedSession, segmentDuration)
         }
         }
     }
     }
@@ -531,6 +563,12 @@ class ReadRecordRepository(
                     .sumOf { it.endTime - it.startTime }
                     .sumOf { it.endTime - it.startTime }
                 ((record?.readTime ?: 0L) - sessionTime).coerceAtLeast(0L)
                 ((record?.readTime ?: 0L) - sessionTime).coerceAtLeast(0L)
             }
             }
+            val detailsBeforeDelete = affectedDevices.associateWith { deviceId ->
+                dao.getDetailsByBook(deviceId, session.bookName, session.bookAuthor)
+            }
+            val sessionsBeforeDelete = affectedDevices.associateWith { deviceId ->
+                dao.getSessionsByBook(deviceId, session.bookName, session.bookAuthor)
+            }
             val daySessionsBeforeDelete = affectedDevices.associateWith { deviceId ->
             val daySessionsBeforeDelete = affectedDevices.associateWith { deviceId ->
                 dao.getSessionsByBookAndDate(
                 dao.getSessionsByBookAndDate(
                     deviceId,
                     deviceId,
@@ -540,67 +578,18 @@ class ReadRecordRepository(
                 )
                 )
             }
             }
             sessionGroup.forEach { dao.deleteSession(it) }
             sessionGroup.forEach { dao.deleteSession(it) }
-            val dateString = session.startTime.toDateString()
             affectedDevices.forEach { deviceId ->
             affectedDevices.forEach { deviceId ->
-                    val record = ReadRecord(
-                        deviceId = deviceId,
-                        bookName = session.bookName,
-                        bookAuthor = session.bookAuthor,
-                    )
-                    val remainingSessions = dao.getSessionsByBookAndDate(
-                        deviceId,
-                        record.bookName,
-                        record.bookAuthor,
-                        dateString,
-                    )
-                    val detail = dao.getDetail(
+                    rebuildDetailsAfterSessionMutation(
                         deviceId,
                         deviceId,
-                        record.bookName,
-                        record.bookAuthor,
-                        dateString,
+                        session.bookName,
+                        session.bookAuthor,
+                        sessionsBeforeDelete[deviceId].orEmpty(),
+                        detailsBeforeDelete[deviceId].orEmpty(),
                     )
                     )
-                    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()) {
-                        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(
-                                deviceId = record.deviceId,
-                                bookName = record.bookName,
-                                bookAuthor = record.bookAuthor,
-                                date = dateString,
-                            )).copy(
-                                readTime = legacyDetailTime + remainingSessions.sumOf { it.endTime - it.startTime },
-                                readWords = legacyDetailWords + remainingSessions.sumOf { it.words },
-                                firstReadTime = remainingSessions
-                                    .map { it.startTime }
-                                    .filter { it > 0L }
-                                    .minOrNull() ?: 0L,
-                                lastReadTime = remainingSessions.maxOf { it.endTime },
-                            )
-                        )
-                    }
                     updateReadRecordTotal(
                     updateReadRecordTotal(
                         deviceId,
                         deviceId,
-                        record.bookName,
-                        record.bookAuthor,
+                        session.bookName,
+                        session.bookAuthor,
                         legacyReadTimes[deviceId] ?: 0L,
                         legacyReadTimes[deviceId] ?: 0L,
                     )
                     )
                 }
                 }
@@ -608,6 +597,40 @@ class ReadRecordRepository(
         }
         }
     }
     }
 
 
+    private suspend fun rebuildDetailsAfterSessionMutation(
+        deviceId: String,
+        bookName: String,
+        bookAuthor: String,
+        oldSessions: List<ReadRecordSession>,
+        oldDetails: List<ReadRecordDetail>,
+    ) {
+        val newSessions = dao.getSessionsByBook(deviceId, bookName, bookAuthor)
+        val oldDurations = oldSessions.durationByDate()
+        val newDurations = newSessions.durationByDate()
+        val oldWords = oldSessions.wordsByStartDate()
+        val newWords = newSessions.wordsByStartDate()
+        val detailsByDate = oldDetails.associateBy { it.date }
+        val dates = (oldDurations.keys + newDurations.keys + oldDetails.map { it.date }).toSet()
+        dates.forEach { date ->
+            val oldDetail = detailsByDate[date]
+            val legacyTime = ((oldDetail?.readTime ?: 0L) - (oldDurations[date] ?: 0L)).coerceAtLeast(0L)
+            val legacyWords = ((oldDetail?.readWords ?: 0L) - (oldWords[date] ?: 0L)).coerceAtLeast(0L)
+            val readTime = legacyTime + (newDurations[date] ?: 0L)
+            val readWords = legacyWords + (newWords[date] ?: 0L)
+            if (readTime <= 0L && readWords <= 0L) {
+                oldDetail?.let { dao.deleteDetail(it) }
+            } else {
+                val daySessions = newSessions.filter { it.startTime.toDateString() == date }
+                dao.insertDetail((oldDetail ?: ReadRecordDetail(deviceId, bookName, bookAuthor, date)).copy(
+                    readTime = readTime,
+                    readWords = readWords,
+                    firstReadTime = daySessions.map { it.startTime }.filter { it > 0L }.minOrNull() ?: oldDetail?.firstReadTime ?: 0L,
+                    lastReadTime = daySessions.maxOfOrNull { it.endTime } ?: oldDetail?.lastReadTime ?: 0L,
+                ))
+            }
+        }
+    }
+
     private fun minPositive(left: Long, right: Long): Long = when {
     private fun minPositive(left: Long, right: Long): Long = when {
         left <= 0L -> right
         left <= 0L -> right
         right <= 0L -> left
         right <= 0L -> left
@@ -729,10 +752,14 @@ class ReadRecordRepository(
         val allDetails = targetDetails + sourceDetails.values.flatten()
         val allDetails = targetDetails + sourceDetails.values.flatten()
         val originalSessions = targetSessions + sourceSessions.values.flatten()
         val originalSessions = targetSessions + sourceSessions.values.flatten()
         val sessionsByRecord = originalSessions.groupBy { Triple(it.deviceId, it.bookName, it.bookAuthor) }
         val sessionsByRecord = originalSessions.groupBy { Triple(it.deviceId, it.bookName, it.bookAuthor) }
-        val sessionsByDate = originalSessions
+        val durationsByRecord = sessionsByRecord.mapValues { it.value.durationByDate() }
+        val wordsByRecord = sessionsByRecord.mapValues { it.value.wordsByStartDate() }
+        val normalizedSessions = originalSessions
             .map { it.copy(deviceId = target.deviceId, bookName = target.bookName, bookAuthor = target.bookAuthor) }
             .map { it.copy(deviceId = target.deviceId, bookName = target.bookName, bookAuthor = target.bookAuthor) }
             .distinctBy { listOf(it.bookName, it.bookAuthor, it.startTime, it.endTime, it.words) }
             .distinctBy { listOf(it.bookName, it.bookAuthor, it.startTime, it.endTime, it.words) }
-            .groupBy { it.startTime.toDateString() }
+        val sessionsByDate = normalizedSessions
+            .flatMap { session -> session.durationByDate().keys.map { it to session } }
+            .groupBy({ it.first }, { it.second })
         val detailsByDate = allDetails.groupBy { it.date }
         val detailsByDate = allDetails.groupBy { it.date }
         dao.deleteDetailsByBook(target.deviceId, target.bookName, target.bookAuthor)
         dao.deleteDetailsByBook(target.deviceId, target.bookName, target.bookAuthor)
         sourceDetails.keys.forEach { dao.deleteDetailsByBook(it.deviceId, it.bookName, it.bookAuthor) }
         sourceDetails.keys.forEach { dao.deleteDetailsByBook(it.deviceId, it.bookName, it.bookAuthor) }
@@ -740,15 +767,13 @@ class ReadRecordRepository(
             val sessions = sessionsByDate[date].orEmpty()
             val sessions = sessionsByDate[date].orEmpty()
             val details = detailsByDate[date].orEmpty()
             val details = detailsByDate[date].orEmpty()
             val legacyTime = details.sumOf { detail ->
             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 })
+                ReadRecordTimeTotals.legacy(detail.readTime, durationsByRecord[Triple(detail.deviceId, detail.bookName, detail.bookAuthor)]?.get(date) ?: 0L)
             }
             }
             val legacyWords = details.sumOf { detail ->
             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)
+                (detail.readWords - (wordsByRecord[Triple(detail.deviceId, detail.bookName, detail.bookAuthor)]?.get(date) ?: 0L)).coerceAtLeast(0L)
             }
             }
-            val sessionTime = sessions.sumOf { it.endTime - it.startTime }
-            val sessionWords = sessions.sumOf { it.words }
+            val sessionTime = sessions.sumOf { it.durationByDate()[date] ?: 0L }
+            val sessionWords = sessions.filter { it.startTime.toDateString() == date }.sumOf { it.words }
             if (sessionTime + legacyTime > 0L || sessionWords + legacyWords > 0L) {
             if (sessionTime + legacyTime > 0L || sessionWords + legacyWords > 0L) {
                 dao.insertDetail(ReadRecordDetail(
                 dao.insertDetail(ReadRecordDetail(
                     deviceId = target.deviceId, bookName = target.bookName, bookAuthor = target.bookAuthor, date = date,
                     deviceId = target.deviceId, bookName = target.bookName, bookAuthor = target.bookAuthor, date = date,
@@ -798,19 +823,21 @@ class ReadRecordRepository(
         oldDetails: List<ReadRecordDetail>,
         oldDetails: List<ReadRecordDetail>,
     ) {
     ) {
         val newSessions = dao.getSessionsByBook(deviceId, bookName, bookAuthor)
         val newSessions = dao.getSessionsByBook(deviceId, bookName, bookAuthor)
-        val oldSessionsByDate = oldSessions.groupBy { it.startTime.toDateString() }
-        val newSessionsByDate = newSessions.groupBy { it.startTime.toDateString() }
+        val oldSessionsByDate = oldSessions.durationByDate()
+        val newSessionsByDate = newSessions.durationByDate()
+        val oldWordsByDate = oldSessions.wordsByStartDate()
+        val newWordsByDate = newSessions.wordsByStartDate()
         val detailsByDate = oldDetails.associateBy { it.date }
         val detailsByDate = oldDetails.associateBy { it.date }
         val dates = (oldSessionsByDate.keys + newSessionsByDate.keys + detailsByDate.keys)
         val dates = (oldSessionsByDate.keys + newSessionsByDate.keys + detailsByDate.keys)
         dates.forEach { date ->
         dates.forEach { date ->
             val oldDetail = detailsByDate[date]
             val oldDetail = detailsByDate[date]
-            val oldSessionTime = oldSessionsByDate[date].orEmpty().sumOf { it.endTime - it.startTime }
-            val oldSessionWords = oldSessionsByDate[date].orEmpty().sumOf { it.words }
+            val oldSessionTime = oldSessionsByDate[date] ?: 0L
+            val oldSessionWords = oldWordsByDate[date] ?: 0L
             val legacyTime = oldDetail?.let { (it.readTime - oldSessionTime).coerceAtLeast(0L) } ?: 0L
             val legacyTime = oldDetail?.let { (it.readTime - oldSessionTime).coerceAtLeast(0L) } ?: 0L
             val legacyWords = oldDetail?.let { (it.readWords - oldSessionWords).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
+            val readTime = (newSessionsByDate[date] ?: 0L) + legacyTime
+            val readWords = (newWordsByDate[date] ?: 0L) + legacyWords
+            val sessions = newSessions.filter { it.startTime.toDateString() == date }
             if (readTime <= 0L && readWords <= 0L) {
             if (readTime <= 0L && readWords <= 0L) {
                 oldDetail?.let { dao.deleteDetail(it) }
                 oldDetail?.let { dao.deleteDetail(it) }
             } else {
             } else {
@@ -940,16 +967,16 @@ class ReadRecordRepository(
             }
             }
             dao.allDetail.forEach { detail ->
             dao.allDetail.forEach { detail ->
                 val sessions = dao.getSessionsByBook(detail.deviceId, detail.bookName, detail.bookAuthor)
                 val sessions = dao.getSessionsByBook(detail.deviceId, detail.bookName, detail.bookAuthor)
-                    .filter {
-                        it.startTime.toDateString() == detail.date
-                    }
-                if (sessions.isEmpty()) return@forEach
+                val sessionTime = sessions.durationByDate()[detail.date] ?: 0L
+                val sessionWords = sessions.wordsByStartDate()[detail.date] ?: 0L
+                if (sessionTime <= 0L && sessionWords <= 0L) return@forEach
                 dao.insertDetail(detail.copy(
                 dao.insertDetail(detail.copy(
-                    readTime = maxOf(detail.readTime, sessions.sumOf { it.endTime - it.startTime }),
-                    readWords = maxOf(detail.readWords, sessions.sumOf { it.words }),
+                    readTime = maxOf(detail.readTime, sessionTime),
+                    readWords = maxOf(detail.readWords, sessionWords),
                     firstReadTime = minPositive(
                     firstReadTime = minPositive(
                         detail.firstReadTime,
                         detail.firstReadTime,
-                        sessions.map { it.startTime }.filter { it > 0L }.minOrNull() ?: 0L,
+                        sessions.filter { it.startTime.toDateString() == detail.date }
+                            .map { it.startTime }.filter { it > 0L }.minOrNull() ?: 0L,
                     ),
                     ),
                     lastReadTime = maxOf(detail.lastReadTime, sessions.maxOf { it.endTime }),
                     lastReadTime = maxOf(detail.lastReadTime, sessions.maxOf { it.endTime }),
                 ))
                 ))

+ 18 - 4
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt

@@ -121,11 +121,10 @@ class ReadRecordViewModel(
             .mapValues { it.value.size }
             .mapValues { it.value.size }
 
 
         val dailyTimes = data.sessions
         val dailyTimes = data.sessions
-            .groupBy { it.startTime.toDateString() }
+            .flatMap { session -> session.durationByDate().entries }
+            .groupingBy { it.key }
+            .fold(0L) { total, entry -> total + entry.value }
             .mapKeys { LocalDate.parse(it.key, DateTimeFormatter.ISO_LOCAL_DATE) }
             .mapKeys { LocalDate.parse(it.key, DateTimeFormatter.ISO_LOCAL_DATE) }
-            .mapValues { (_, sessions) ->
-                sessions.sumOf { (it.endTime - it.startTime).coerceAtLeast(0L) }
-            }
 
 
         val filteredDetails = data.details.filter { detail ->
         val filteredDetails = data.details.filter { detail ->
             dateStr == null || detail.date == dateStr
             dateStr == null || detail.date == dateStr
@@ -349,6 +348,21 @@ class ReadRecordViewModel(
 
 
     private fun Long.toDateString(): String =
     private fun Long.toDateString(): String =
         Instant.ofEpochMilli(this).atZone(ZoneId.systemDefault()).toLocalDate().toString()
         Instant.ofEpochMilli(this).atZone(ZoneId.systemDefault()).toLocalDate().toString()
+
+    private fun ReadRecordSession.durationByDate(): Map<String, Long> {
+        if (endTime <= startTime) return emptyMap()
+        val zone = ZoneId.systemDefault()
+        var cursor = startTime
+        val result = linkedMapOf<String, Long>()
+        while (cursor < endTime) {
+            val date = java.time.Instant.ofEpochMilli(cursor).atZone(zone).toLocalDate()
+            val nextMidnight = date.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
+            val segmentEnd = minOf(endTime, nextMidnight)
+            result[date.toString()] = (result[date.toString()] ?: 0L) + (segmentEnd - cursor)
+            cursor = segmentEnd
+        }
+        return result
+    }
 }
 }
 
 
 sealed interface ReadRecordIntent {
 sealed interface ReadRecordIntent {

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

@@ -20,6 +20,8 @@ import org.junit.runner.RunWith
 import org.robolectric.RobolectricTestRunner
 import org.robolectric.RobolectricTestRunner
 import org.robolectric.RuntimeEnvironment
 import org.robolectric.RuntimeEnvironment
 import org.robolectric.annotation.Config
 import org.robolectric.annotation.Config
+import java.time.ZoneId
+import java.time.ZonedDateTime
 
 
 @RunWith(RobolectricTestRunner::class)
 @RunWith(RobolectricTestRunner::class)
 @Config(application = Application::class, sdk = [35])
 @Config(application = Application::class, sdk = [35])
@@ -241,6 +243,34 @@ class ReadRecordRepositoryTest {
         assertEquals(listOf(1_431_000L), remaining.map { it.startTime })
         assertEquals(listOf(1_431_000L), remaining.map { it.startTime })
     }
     }
 
 
+    @Test
+    fun `cross midnight session splits daily read time without splitting the session`() = runBlocking {
+        val zone = ZoneId.systemDefault()
+        val midnight = ZonedDateTime.of(2026, 9, 23, 0, 0, 0, 0, zone).toInstant().toEpochMilli()
+        val record = session(bookUrl = "https://book.example", start = midnight - 120_000, end = midnight + 480_000)
+
+        repository.saveReadSession(record)
+
+        val previousDay = java.time.Instant.ofEpochMilli(record.startTime).atZone(zone).toLocalDate().toString()
+        val nextDay = java.time.Instant.ofEpochMilli(record.endTime).atZone(zone).toLocalDate().toString()
+        assertEquals(120_000L, database.readRecordDao.getDetail(deviceId, targetName, author, previousDay)?.readTime)
+        assertEquals(480_000L, database.readRecordDao.getDetail(deviceId, targetName, author, nextDay)?.readTime)
+        assertEquals(1, database.readRecordDao.getSessionsByBook(deviceId, targetName, author).size)
+    }
+
+    @Test
+    fun `deleting cross midnight session clears both daily details`() = runBlocking {
+        val zone = ZoneId.systemDefault()
+        val midnight = ZonedDateTime.of(2026, 9, 24, 0, 0, 0, 0, zone).toInstant().toEpochMilli()
+        val record = session(bookUrl = "https://book.example", start = midnight - 120_000, end = midnight + 480_000)
+        repository.saveReadSession(record)
+
+        assertEquals(true, repository.deleteSession(database.readRecordDao.allSession.single()))
+
+        assertEquals(0, database.readRecordDao.allDetail.size)
+        assertEquals(0, database.readRecordDao.allSession.size)
+    }
+
     @Test
     @Test
     fun `coexisting copies are timed independently while the work total is their sum`() =
     fun `coexisting copies are timed independently while the work total is their sum`() =
         runBlocking {
         runBlocking {