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

更加智能的阅读记录逻辑

HapeLee 11 месяцев назад
Родитель
Сommit
16b89c43f3

+ 13 - 0
app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt

@@ -92,6 +92,19 @@ interface ReadRecordDao {
     @Query("DELETE FROM readRecordDetail WHERE bookName = :bookName")
     fun deleteDetailByName(bookName: String)
 
+    /**
+     * 获取指定书籍的最后一条阅读会话
+     * 用于判断是否可以合并
+     */
+    @Query("SELECT * FROM readRecordSession WHERE bookName = :bookName ORDER BY endTime DESC LIMIT 1")
+    suspend fun getLatestSessionByBook(bookName: String): ReadRecordSession?
+
+    /**
+     * 更新现有的会话
+     */
+    @Update
+    suspend fun updateSession(session: ReadRecordSession)
+
     /**
      * 插入阅读会话记录。
      */

+ 82 - 11
app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt

@@ -15,10 +15,64 @@ class ReadRecordRepository(
     private val dao: ReadRecordDao
 ) {
 
+    companion object {
+        // 2分钟内重新打开书本,视为同一次阅读
+        private const val CONTINUE_THRESHOLD = 120 * 1000L
+        // 会话总时长小于10秒则不记录
+        private const val MIN_READ_DURATION = 0 * 1000L
+    }
+
     private fun getCurrentDeviceId(): String = ""
 
     /**
-     * 保存一个完整的阅读会话,并同步更新 ReadRecordDetail 和 ReadRecord。
+     * 智能保存阅读会话。
+     * 自动判断是插入新会话还是合并到上一次会话
+     */
+    @Transaction
+    suspend fun saveOrMergeReadSession(newSession: ReadRecordSession) {
+
+        val segmentDuration = newSession.endTime - newSession.startTime
+
+        if (segmentDuration < MIN_READ_DURATION) {
+            return
+        }
+
+        val latestSession = dao.getLatestSessionByBook(newSession.bookName)
+
+        if (latestSession != null) {
+            val timeGap = newSession.startTime - latestSession.endTime
+
+            if (timeGap <= CONTINUE_THRESHOLD && timeGap >= 0) {
+
+                val durationDelta = segmentDuration
+                val wordsDelta = newSession.words
+
+                val mergedSession = latestSession.copy(
+                    endTime = newSession.endTime,
+                    words = latestSession.words + wordsDelta
+                )
+
+                dao.updateSession(mergedSession)
+
+                val dateString = DateUtil.format(Date(mergedSession.startTime), DatePattern.NORM_DATE_PATTERN)
+                updateReadRecordDetail(mergedSession, durationDelta, wordsDelta, dateString)
+                updateReadRecord(mergedSession, durationDelta)
+
+                return
+            }
+        }
+
+        // 处理新会话
+        dao.insertSession(newSession)
+
+        val dateString = DateUtil.format(Date(newSession.startTime), DatePattern.NORM_DATE_PATTERN)
+        updateReadRecordDetail(newSession, segmentDuration, newSession.words, dateString)
+        updateReadRecord(newSession, segmentDuration)
+    }
+
+    /**
+     * 保存一个完整的阅读会话
+     * 没几把用
      */
     @Transaction
     suspend fun saveReadSession(session: ReadRecordSession) {
@@ -26,17 +80,23 @@ class ReadRecordRepository(
 
         val sessionDuration = session.endTime - session.startTime
         val dateString = DateUtil.format(Date(session.startTime), DatePattern.NORM_DATE_PATTERN)
+        updateReadRecordDetail(session, sessionDuration, session.words, dateString)
 
-        updateReadRecordDetail(session, sessionDuration, dateString)
         updateReadRecord(session, sessionDuration)
     }
 
-    private suspend fun updateReadRecord(session: ReadRecordSession, sessionDuration: Long) {
+    /**
+     * 更新总记录表 (ReadRecord)
+     * @param durationDelta 增加的时长
+     */
+    private suspend fun updateReadRecord(session: ReadRecordSession, durationDelta: Long) {
+        if (durationDelta <= 0) return
+
         val existingRecord = dao.getReadRecord(session.deviceId, session.bookName)
 
         if (existingRecord != null) {
             val updatedRecord = existingRecord.copy(
-                readTime = existingRecord.readTime + sessionDuration,
+                readTime = existingRecord.readTime + durationDelta,
                 lastRead = session.endTime
             )
             dao.update(updatedRecord)
@@ -44,19 +104,31 @@ class ReadRecordRepository(
             val newRecord = ReadRecord(
                 deviceId = session.deviceId,
                 bookName = session.bookName,
-                readTime = sessionDuration,
+                readTime = durationDelta,
                 lastRead = session.endTime
             )
             dao.insert(newRecord)
         }
     }
 
-    private suspend fun updateReadRecordDetail(session: ReadRecordSession, sessionDuration: Long, dateString: String) {
+    /**
+     * 更新每日详情表 (ReadRecordDetail)
+     * @param durationDelta 增加的时长
+     * @param wordsDelta 增加的字数
+     */
+    private suspend fun updateReadRecordDetail(
+        session: ReadRecordSession,
+        durationDelta: Long,
+        wordsDelta: Long,
+        dateString: String
+    ) {
+        if (durationDelta <= 0 && wordsDelta <= 0) return
+
         val existingDetail = dao.getDetail(session.deviceId, session.bookName, dateString)
 
         if (existingDetail != null) {
-            existingDetail.readTime += sessionDuration
-            existingDetail.readWords += session.words
+            existingDetail.readTime += durationDelta
+            existingDetail.readWords += wordsDelta
             existingDetail.firstReadTime = min(existingDetail.firstReadTime, session.startTime)
             existingDetail.lastReadTime = max(existingDetail.lastReadTime, session.endTime)
             dao.insertDetail(existingDetail)
@@ -65,8 +137,8 @@ class ReadRecordRepository(
                 deviceId = session.deviceId,
                 bookName = session.bookName,
                 date = dateString,
-                readTime = sessionDuration,
-                readWords = session.words,
+                readTime = durationDelta,
+                readWords = wordsDelta,
                 firstReadTime = session.startTime,
                 lastReadTime = session.endTime
             )
@@ -115,5 +187,4 @@ class ReadRecordRepository(
     // 暴露总时长
     val allTime: Long
         get() = dao.allTime
-
 }

+ 63 - 57
app/src/main/java/io/legado/app/model/ReadBook.kt

@@ -47,6 +47,7 @@ import kotlinx.coroutines.delay
 import kotlinx.coroutines.ensureActive
 import kotlinx.coroutines.flow.collect
 import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.Semaphore
@@ -99,8 +100,20 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     var contentProcessor: ContentProcessor? = null
     val downloadScope = CoroutineScope(SupervisorJob() + IO)
     val preDownloadSemaphore = Semaphore(2)
+
     val executor = globalExecutor
 
+    private val ioScope = CoroutineScope(IO)
+
+    private var autoSaveJob: Job? = null
+
+    private var currentActiveSession: ReadRecordSession? = null
+    //占位
+    private var currentReadLength: Long = 10L
+    private const val AUTO_SAVE_INTERVAL = 120 * 1000L
+
+    private const val MIN_READ_DURATION = 10 * 1000L
+
     fun resetData(book: Book) {
         ReadBook.book = book
         readRecord.bookName = book.name
@@ -286,91 +299,84 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         }
     }
 
-    // 阅读暂停的阈值
-    private const val PAUSE_THRESHOLD = 5 * 60 * 1000L
-    // 存储当前正在累积的阅读会话对象
-    private var currentActiveSession: ReadRecordSession? = null
-    //占位
-    private var currentReadLength: Long = 10L
+    fun initReadTime() {
+        val currentBookName = book?.name ?: return
+        if (currentActiveSession != null && currentActiveSession!!.bookName != currentBookName) {
+            commitReadSession()
+        }
+
+        if (currentActiveSession == null) {
+            lastReadLength = currentReadLength
+            currentActiveSession = ReadRecordSession(
+                deviceId = "",
+                bookName = currentBookName,
+                startTime = readStartTime,
+                endTime = readStartTime,
+                words = 0
+            )
+        }
+    }
 
     fun upReadTime() {
         val currentLength = currentReadLength
-        if (!AppConfig.enableReadRecord) {
-            return
-        }
         val currentBookName = book?.name ?: return
         val endTime = System.currentTimeMillis()
 
-        //计算本次片段的时长和字数
-        val duration = endTime - readStartTime
+        if (currentActiveSession == null || currentActiveSession!!.bookName != currentBookName) {
+            initReadTime()
+            return
+        }
+
         var wordChange = currentLength - lastReadLength
         if (wordChange < 0) wordChange = 0
 
-        //过滤无效记录
-        if (duration < 1000L && wordChange == 0L) {
-            readStartTime = endTime
-            lastReadLength = currentLength
-            return
-        }
+        currentActiveSession = currentActiveSession!!.copy(
+            endTime = endTime,
+            words = currentActiveSession!!.words + wordChange
+        )
 
-        //检查是否中断
-        if (currentActiveSession != null) {
-            // 计算从上一个片段结束到当前片段开始的时间间隔
-            val timeSinceLastUpdate = readStartTime - currentActiveSession!!.endTime
+        readStartTime = endTime
+        lastReadLength = currentLength
+    }
 
-            if (timeSinceLastUpdate > PAUSE_THRESHOLD) {
-                // 如果间隔时间过长,说明用户暂停阅读后又回来了,提交旧会话
-                commitReadSession()
+    fun startAutoSaveSession() {
+        autoSaveJob?.cancel()
+        autoSaveJob = ioScope.launch {
+            while (isActive) {
+                delay(AUTO_SAVE_INTERVAL)
+                commitSessionInternal()
             }
         }
+    }
 
+    fun stopAutoSaveSession() {
+        autoSaveJob?.cancel()
+        autoSaveJob = null
+    }
 
-        //累加或创建新会话
-        if (currentActiveSession == null) {
-            // 创建新的会话对象,使用 readStartTime 作为整个会话的起始时间
-            currentActiveSession = ReadRecordSession(
-                deviceId = "",
-                bookName = currentBookName,
-                startTime = readStartTime,
-                endTime = endTime,
-                words = 0
-            )
-        } else {
-            // 累加数据:只更新结束时间和总字数
-            currentActiveSession = currentActiveSession!!.copy(
-                endTime = endTime,
-                words = currentActiveSession!!.words + wordChange
-            )
+    fun commitReadSession() {
+        ioScope.launch {
+            commitSessionInternal()
         }
-
-        readStartTime = endTime
-        lastReadLength = currentLength
     }
 
     /**
-     * 将当前累积的阅读会话(ReadRecordSession)写入数据库,并重置状态。
-     * 此方法必须在后台线程上调用。
+     * 内部提交逻辑
      */
-    fun commitReadSession() {
-        // 检查是否有需要保存的数据
+    private suspend fun commitSessionInternal() {
         val sessionToSave = currentActiveSession ?: return
-
-        // 确保时长和字数有效
-        if (sessionToSave.words <= 0 && (sessionToSave.endTime - sessionToSave.startTime) < 10L) {
+        val sessionDuration = sessionToSave.endTime - sessionToSave.startTime
+        if (sessionDuration < MIN_READ_DURATION) {
             currentActiveSession = null
             return
         }
-
-        //使用 runBlocking 确保数据库保存操作在当前线程上同步完成
         try {
-            kotlinx.coroutines.runBlocking {
-                readRecordRepository.saveReadSession(sessionToSave)
-            }
+            readRecordRepository.saveOrMergeReadSession(sessionToSave)
         } catch (e: Exception) {
             AppLog.put("保存阅读会话出错: ${sessionToSave.bookName}", e)
+        } finally {
+            currentActiveSession = null
         }
-
-        currentActiveSession = null
     }
 
     fun upMsg(msg: String?) {

+ 61 - 58
app/src/main/java/io/legado/app/model/ReadManga.kt

@@ -39,6 +39,7 @@ import kotlinx.coroutines.delay
 import kotlinx.coroutines.ensureActive
 import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.sync.Semaphore
 import org.koin.core.component.KoinComponent
@@ -75,6 +76,16 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
 
     private val readRecordRepository: ReadRecordRepository by inject()
 
+    private val ioScope = CoroutineScope(IO)
+
+    private var autoSaveJob: Job? = null
+
+    private var currentActiveSession: ReadRecordSession? = null
+    private var currentReadLength: Long = 10L
+    private const val AUTO_SAVE_INTERVAL = 120 * 1000L
+
+    private const val MIN_READ_DURATION = 10 * 1000L
+
     private var lastReadLength: Long = 0
     fun resetData(book: Book) {
         ReadManga.book = book
@@ -134,92 +145,84 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
         nextMangaChapter = null
     }
 
-    //每次切换章节更新阅读记录
-    // 阅读暂停的阈值
-    private const val PAUSE_THRESHOLD = 5 * 60 * 1000L
-    // 存储当前正在累积的阅读会话对象
-    private var currentActiveSession: ReadRecordSession? = null
-    //占位
-    private var currentReadLength: Long = 10L
+    fun initReadTime() {
+        val currentBookName = book?.name ?: return
+        if (currentActiveSession != null && currentActiveSession!!.bookName != currentBookName) {
+            commitReadSession()
+        }
+
+        if (currentActiveSession == null) {
+            lastReadLength = currentReadLength
+            currentActiveSession = ReadRecordSession(
+                deviceId = "",
+                bookName = currentBookName,
+                startTime = readStartTime,
+                endTime = readStartTime,
+                words = 0
+            )
+        }
+    }
 
     fun upReadTime() {
         val currentLength = currentReadLength
-        if (!AppConfig.enableReadRecord) {
-            return
-        }
         val currentBookName = book?.name ?: return
         val endTime = System.currentTimeMillis()
 
-        //计算本次片段的时长和字数
-        val duration = endTime - readStartTime
+        if (currentActiveSession == null || currentActiveSession!!.bookName != currentBookName) {
+            initReadTime()
+            return
+        }
+
         var wordChange = currentLength - lastReadLength
         if (wordChange < 0) wordChange = 0
 
-        //过滤无效记录
-        if (duration < 1000L && wordChange == 0L) {
-            readStartTime = endTime
-            lastReadLength = currentLength
-            return
-        }
+        currentActiveSession = currentActiveSession!!.copy(
+            endTime = endTime,
+            words = currentActiveSession!!.words + wordChange
+        )
 
-        //检查是否中断
-        if (currentActiveSession != null) {
-            // 计算从上一个片段结束到当前片段开始的时间间隔
-            val timeSinceLastUpdate = readStartTime - currentActiveSession!!.endTime
+        readStartTime = endTime
+        lastReadLength = currentLength
+    }
 
-            if (timeSinceLastUpdate > PAUSE_THRESHOLD) {
-                // 如果间隔时间过长,说明用户暂停阅读后又回来了,提交旧会话
-                commitReadSession()
+    fun startAutoSaveSession() {
+        autoSaveJob?.cancel()
+        autoSaveJob = ioScope.launch {
+            while (isActive) {
+                delay(AUTO_SAVE_INTERVAL)
+                commitSessionInternal()
             }
         }
+    }
 
+    fun stopAutoSaveSession() {
+        autoSaveJob?.cancel()
+        autoSaveJob = null
+    }
 
-        //累加或创建新会话
-        if (currentActiveSession == null) {
-            // 创建新的会话对象,使用 readStartTime 作为整个会话的起始时间
-            currentActiveSession = ReadRecordSession(
-                deviceId = "",
-                bookName = currentBookName,
-                startTime = readStartTime,
-                endTime = endTime,
-                words = 0
-            )
-        } else {
-            // 累加数据:只更新结束时间和总字数
-            currentActiveSession = currentActiveSession!!.copy(
-                endTime = endTime,
-                words = currentActiveSession!!.words + wordChange
-            )
+    fun commitReadSession() {
+        ioScope.launch {
+            commitSessionInternal()
         }
-
-        readStartTime = endTime
-        lastReadLength = currentLength
     }
 
     /**
-     * 将当前累积的阅读会话(ReadRecordSession)写入数据库,并重置状态。
-     * 此方法必须在后台线程上调用。
+     * 内部提交逻辑
      */
-    fun commitReadSession() {
-        // 检查是否有需要保存的数据
+    private suspend fun commitSessionInternal() {
         val sessionToSave = currentActiveSession ?: return
-
-        // 确保时长和字数有效
-        if (sessionToSave.words <= 0 && (sessionToSave.endTime - sessionToSave.startTime) < 10L) {
+        val sessionDuration = sessionToSave.endTime - sessionToSave.startTime
+        if (sessionDuration < MIN_READ_DURATION) {
             currentActiveSession = null
             return
         }
-
-        //使用 runBlocking 确保数据库保存操作在当前线程上同步完成
         try {
-            kotlinx.coroutines.runBlocking {
-                readRecordRepository.saveReadSession(sessionToSave)
-            }
+            readRecordRepository.saveOrMergeReadSession(sessionToSave)
         } catch (e: Exception) {
             AppLog.put("保存阅读会话出错: ${sessionToSave.bookName}", e)
+        } finally {
+            currentActiveSession = null
         }
-
-        currentActiveSession = null
     }
 
     @Synchronized

+ 3 - 0
app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt

@@ -429,6 +429,8 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
         if (enableAutoScroll) {
             mScrollTimer.isEnabled = true
         }
+        ReadManga.initReadTime()
+        ReadManga.startAutoSaveSession()
     }
 
     override fun onPause() {
@@ -444,6 +446,7 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
                 Backup.autoBack(this)
             }
         }
+        ReadManga.stopAutoSaveSession()
         ReadManga.commitReadSession()
         ReadManga.cancelPreDownloadTask()
         networkChangedListener.unRegister()

+ 3 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt

@@ -392,6 +392,8 @@ class ReadBookActivity : BaseReadBookActivity(),
     override fun onResume() {
         super.onResume()
         ReadBook.readStartTime = System.currentTimeMillis()
+        ReadBook.initReadTime()
+        ReadBook.startAutoSaveSession()
         if (bookChanged) {
             bookChanged = false
             ReadBook.callBack = this
@@ -423,6 +425,7 @@ class ReadBookActivity : BaseReadBookActivity(),
         autoPageStop()
         backupJob?.cancel()
         ReadBook.saveRead()
+        ReadBook.stopAutoSaveSession()
         ReadBook.commitReadSession()
         ReadBook.cancelPreDownloadTask()
         unregisterReceiver(timeBatteryReceiver)

+ 4 - 3
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordActivity.kt

@@ -325,7 +325,7 @@ fun TimelineSessionItem(
     val timelineX = 24.dp
     val contentPaddingStart = 32.dp
 
-    val lineColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
+    val lineColor = MaterialTheme.colorScheme.surfaceContainerHigh
     val nodeColor = MaterialTheme.colorScheme.primary
 
     Box(
@@ -364,7 +364,7 @@ fun TimelineSessionItem(
                 verticalArrangement = Arrangement.Center
             ) {
                 Text(
-                    text = startTimeText,
+                    text = endTimeText,
                     style = MaterialTheme.typography.bodySmall
                 )
             }
@@ -385,7 +385,8 @@ fun TimelineSessionItem(
 
                 Text(
                     "时长: ${formatDuring(duration)}",
-                    style = MaterialTheme.typography.bodySmall
+                    style = MaterialTheme.typography.bodySmall,
+                    color = Color.Gray
                 )
                 // Text(
                 //     "字数: ${session.words}",

+ 85 - 0
app/src/main/java/io/legado/app/ui/widget/compose/EmptyMessageView.kt

@@ -0,0 +1,85 @@
+package io.legado.app.ui.widget.compose
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+@Composable
+fun EmptyMessageView(
+    message: String,
+    modifier: Modifier = Modifier,
+    faces: List<String> = listOf(
+        "(;′⌒`)", "(つ﹏⊂)", "(•̀ᴗ•́)و", "(๑•́ ₃ •̀๑)",
+        "(눈‸눈)", "(ಥ﹏ಥ)", "(。•́︿•̀。)"
+    ),
+    faceTextSize: TextUnit = 32.sp,
+    onFaceClick: (() -> Unit)? = null
+) {
+    var currentFace by remember { mutableStateOf(faces.random()) }
+
+    Column(
+        modifier = modifier
+            .wrapContentSize()
+            .padding(16.dp),
+        horizontalAlignment = Alignment.CenterHorizontally
+    ) {
+        AnimatedTextLine(
+            text = currentFace,
+            fontSize = faceTextSize,
+            textAlign = TextAlign.Center,
+            modifier = Modifier
+                .clickable {
+                    currentFace = faces.random()
+                    onFaceClick?.invoke()
+                }
+        )
+
+        Spacer(modifier = Modifier.height(8.dp))
+
+        AnimatedTextLine(
+            text = message,
+            textAlign = TextAlign.Center,
+            maxLines = 2,
+            softWrap = true,
+            modifier = Modifier.widthIn(max = 240.dp)
+        )
+    }
+}
+
+@Composable
+fun EmptyMessageView(
+    @StringRes messageResId: Int,
+    modifier: Modifier = Modifier,
+    faces: List<String> = listOf(
+        "(;′⌒`)", "(つ﹏⊂)", "(•̀ᴗ•́)و", "(๑•́ ₃ •̀๑)",
+        "(눈‸눈)", "(ಥ﹏ಥ)", "(。•́︿•̀。)"
+    ),
+    faceTextSize: TextUnit = 32.sp,
+    onFaceClick: (() -> Unit)? = null
+) {
+    val message = stringResource(id = messageResId)
+    EmptyMessageView(
+        message = message,
+        modifier = modifier,
+        faces = faces,
+        faceTextSize = faceTextSize,
+        onFaceClick = onFaceClick
+    )
+}