Explorar o código

Merge branch 'main' of https://github.com/HapeLee/legado-with-MD3

HapeLee hai 2 meses
pai
achega
db673cb1ef

+ 5 - 0
app/src/main/java/io/legado/app/data/dao/BookDao.kt

@@ -810,6 +810,11 @@ interface BookDao {
     @Query("update books set durChapterPos = :pos where bookUrl = :bookUrl")
     fun upProgress(bookUrl: String, pos: Int)
 
+    @Query(
+        """update books set lastCheckCount = 0, durChapterIndex = :durChapterIndex, durChapterPos = :durChapterPos, durChapterTime = :durChapterTime where bookUrl = :bookUrl"""
+    )
+    fun upReadProgress(bookUrl: String, durChapterIndex: Int, durChapterPos: Int, durChapterTime: Long)
+
     @Query("update books set `group` = :newGroupId where `group` = :oldGroupId")
     fun upGroup(oldGroupId: Long, newGroupId: Long)
 

+ 45 - 1
app/src/main/java/io/legado/app/model/ReadBook.kt

@@ -61,6 +61,7 @@ import io.legado.app.ui.book.read.pageestimate.RoomExactChapterPageCountStore
 import io.legado.app.ui.book.read.pageestimate.WholeBookPageCoordinator
 import io.legado.app.ui.book.read.pageestimate.WholeBookPageState
 import io.legado.app.ui.config.readConfig.ReadConfig
+import io.legado.app.utils.buildMainHandler
 import io.legado.app.utils.dpToPx
 import io.legado.app.utils.postEvent
 import io.legado.app.utils.stackTraceStr
@@ -1666,6 +1667,30 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         saveRead()
     }
 
+    // 页内翻页进度窄更新的防抖窗口:窗口内的写入合并,尾随任务保证最终位置落库
+    private const val PROGRESS_SAVE_DEBOUNCE_MS = 300L
+
+    private var lastProgressSaveAt = 0L
+    private var pendingProgressSave = false
+    private val progressSaveHandler by lazy { buildMainHandler() }
+
+    private val pendingProgressSaveRunnable = Runnable {
+        executor.execute {
+            if (!pendingProgressSave) return@execute
+            pendingProgressSave = false
+            val book = book ?: return@execute
+            // 期间已发生全量保存(切章等)或进度再次变化,交由后续 saveRead 落库
+            if (book.durChapterIndex != durChapterIndex) return@execute
+            appDb.bookDao.upReadProgress(
+                bookUrl = book.bookUrl,
+                durChapterIndex = book.durChapterIndex,
+                durChapterPos = book.durChapterPos,
+                durChapterTime = book.durChapterTime,
+            )
+            lastProgressSaveAt = book.durChapterTime
+        }
+    }
+
     fun saveRead(pageChanged: Boolean = false) {
         val book = book ?: return
         executor.execute {
@@ -1676,6 +1701,8 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                 book.durChapterIndex = durChapterIndex
                 book.durChapterPos = durChapterPos
                 if (!pageChanged || chapterChanged) {
+                    pendingProgressSave = false
+                    progressSaveHandler.removeCallbacks(pendingProgressSaveRunnable)
                     appDb.bookChapterDao.getChapter(book.bookUrl, durChapterIndex)?.let {
                         book.durChapterTitle = it.getDisplayTitle(
                             ContentProcessor.get(book.name, book.origin).getTitleReplaceRules(),
@@ -1684,8 +1711,25 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                         )
                         SourceCallBack.callBackBook(SourceCallBack.SAVE_READ, bookSource, book, it)
                     }
+                    book.update()
+                    lastProgressSaveAt = book.durChapterTime
+                } else if (book.durChapterTime - lastProgressSaveAt < PROGRESS_SAVE_DEBOUNCE_MS) {
+                    // 快速翻页:窗口内合并窄更新,尾随任务写入最终位置
+                    if (!pendingProgressSave) {
+                        pendingProgressSave = true
+                        progressSaveHandler.postDelayed(
+                            pendingProgressSaveRunnable, PROGRESS_SAVE_DEBOUNCE_MS
+                        )
+                    }
+                } else {
+                    appDb.bookDao.upReadProgress(
+                        bookUrl = book.bookUrl,
+                        durChapterIndex = book.durChapterIndex,
+                        durChapterPos = book.durChapterPos,
+                        durChapterTime = book.durChapterTime,
+                    )
+                    lastProgressSaveAt = book.durChapterTime
                 }
-                book.update()
             }.onFailure {
                 AppLog.put("保存书籍阅读进度信息出错\n$it", it)
             }

+ 19 - 2
app/src/main/java/io/legado/app/service/BaseReadAloudService.kt

@@ -10,6 +10,7 @@ import android.content.Intent
 import android.content.IntentFilter
 import android.graphics.Bitmap
 import android.graphics.BitmapFactory
+import android.graphics.Color
 import android.media.AudioManager
 import android.net.wifi.WifiManager
 import android.os.Bundle
@@ -62,6 +63,7 @@ import io.legado.app.ui.main.MainActivity
 import io.legado.app.utils.LogUtils
 import io.legado.app.utils.activityPendingIntent
 import io.legado.app.utils.getPrefBoolean
+import io.legado.app.utils.isNightMode
 import io.legado.app.utils.observeEvent
 import io.legado.app.utils.postEvent
 import io.legado.app.utils.toastOnUi
@@ -139,6 +141,8 @@ abstract class BaseReadAloudService : BaseService(),
 
         private const val TAG = "BaseReadAloudService"
         private const val ACTION_ADD_TIMER = "io.legado.app.action.ADD_READ_ALOUD_TIMER"
+        private const val ACTION_OPEN_MEDIA_CONTROL_READER =
+            "io.legado.app.action.OPEN_READ_ALOUD_MEDIA_CONTROL"
 
         /**
          * 语速 1.0x 时的每秒朗读字数估算值, 用于把字符进度换算成媒体播放器时间轴
@@ -923,7 +927,7 @@ abstract class BaseReadAloudService : BaseService(),
      */
     @SuppressLint("UnspecifiedImmutableFlag")
     private fun initMediaSession() {
-        mediaSessionCompat.setSessionActivity(readAloudActivityPendingIntent())
+        mediaSessionCompat.setSessionActivity(readAloudMediaControlActivityPendingIntent())
         mediaSessionCompat.setCallback(object : MediaSessionCompat.Callback() {
             override fun onPlay() {
                 resumeReadAloud()
@@ -1192,11 +1196,19 @@ abstract class BaseReadAloudService : BaseService(),
             .setOnlyAlertOnce(true)
             .setContentTitle(ReadBook.book?.name ?: getString(R.string.read_aloud))
             .setContentText(chapterTitle)
-            .setContentIntent(readAloudActivityPendingIntent())
+            .setContentIntent(readAloudMediaControlActivityPendingIntent())
             .setLargeIcon(cover)
             .setVibrate(null)
             .setSound(null)
             .setLights(0, 0, 0)
+            .apply {
+                if (!resources.configuration.isNightMode) {
+                    // Some OEM media controls derive a white progress tint from light artwork.
+                    // Keep the system surface and provide a contrasting accent in light mode.
+                    setColor(Color.BLACK)
+                    setColorized(false)
+                }
+            }
             .addAction(
                 R.drawable.ic_skip_previous,
                 previousLabel,
@@ -1231,6 +1243,11 @@ abstract class BaseReadAloudService : BaseService(),
         "activity",
     )
 
+    private fun readAloudMediaControlActivityPendingIntent(): PendingIntent? = activityPendingIntent(
+        MainActivity.createReadBookMediaControlIntent(this),
+        ACTION_OPEN_MEDIA_CONTROL_READER,
+    )
+
     /**
      * 更新通知
      */

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt

@@ -1172,7 +1172,7 @@ private fun BookInfoActions(
         BookInfoActionCard(
             modifier = Modifier.weight(1f),
             icon = Icons.Default.Code,
-            label = stringResource(R.string.book_source),
+            label = stringResource(R.string.change_origin),
             onClick = onSourceClick
         )
         BookInfoActionCard(

+ 28 - 4
app/src/main/java/io/legado/app/ui/main/MainActivity.kt

@@ -83,6 +83,11 @@ import kotlin.coroutines.suspendCoroutine
 open class MainActivity : BaseComposeActivity(), AudioPlay.CallBack,
     ChangeBookSourceDialog.CallBack {
 
+    private data class RouteEvent(
+        val route: NavKey,
+        val resetToHome: Boolean,
+    )
+
     /** 当前激活的有声书播放器 ViewModel(由有声书路由在生命周期内设置/清理) */
     internal var activeAudioPlayViewModel: AudioPlayViewModel? = null
 
@@ -187,6 +192,9 @@ open class MainActivity : BaseComposeActivity(), AudioPlay.CallBack,
             chapterChanged = chapterChanged,
         )
 
+        fun createReadBookMediaControlIntent(context: Context): Intent =
+            MainIntent.createReadBookMediaControlIntent(context)
+
         fun createReadMangaIntent(
             context: Context,
             bookUrl: String? = null,
@@ -275,7 +283,7 @@ open class MainActivity : BaseComposeActivity(), AudioPlay.CallBack,
     private val otherSettingsGateway by inject<OtherSettingsGateway>()
     private val mangaSettingsGateway by inject<MangaSettingsGateway>()
     private val backupSettingsGateway by inject<BackupSettingsGateway>()
-    private val routeEvents = MutableSharedFlow<NavKey>(extraBufferCapacity = 1)
+    private val routeEvents = MutableSharedFlow<RouteEvent>(extraBufferCapacity = 1)
     private var shouldApplyDefaultToRead = true
     private var restoredReadBookRoute: MainRouteReadBook? = null
     private var latestBackStack: List<NavKey> = emptyList()
@@ -321,7 +329,12 @@ open class MainActivity : BaseComposeActivity(), AudioPlay.CallBack,
         super.onNewIntent(intent)
         setIntent(intent)
         if (!intent.hasExplicitStartRoute()) return
-        routeEvents.tryEmit(MainNavigator.resolveStartRoute(intent))
+        routeEvents.tryEmit(
+            RouteEvent(
+                route = MainNavigator.resolveStartRoute(intent),
+                resetToHome = MainIntent.shouldOpenRouteWithHomeParent(intent),
+            )
+        )
     }
 
     @OptIn(ExperimentalSharedTransitionApi::class)
@@ -355,6 +368,13 @@ open class MainActivity : BaseComposeActivity(), AudioPlay.CallBack,
             val resolved = MainNavigator.resolveStartRoute(intent)
             val hasExplicitStartRoute = intent?.hasExplicitStartRoute() == true
             when {
+                MainIntent.shouldOpenRouteWithHomeParent(intent) -> {
+                    if (resolved == MainRouteHome) {
+                        arrayOf(MainRouteHome)
+                    } else {
+                        arrayOf(MainRouteHome, resolved)
+                    }
+                }
                 !hasExplicitStartRoute && restoredReadBookRoute != null -> {
                     arrayOf(MainRouteHome, restoredReadBookRoute!!)
                 }
@@ -379,8 +399,12 @@ open class MainActivity : BaseComposeActivity(), AudioPlay.CallBack,
         }
 
         LaunchedEffect(backStack) {
-            routeEvents.collect { route ->
-                MainNavigator.navigateToRoute(backStack, route)
+            routeEvents.collect { event ->
+                MainNavigator.navigateToRoute(
+                    backStack = backStack,
+                    route = event.route,
+                    resetToHome = event.resetToHome,
+                )
             }
         }
 

+ 16 - 0
app/src/main/java/io/legado/app/ui/main/MainIntent.kt

@@ -6,6 +6,7 @@ import io.legado.app.ui.config.ConfigTag
 
 object MainIntent {
     const val EXTRA_START_ROUTE = "startRoute"
+    internal const val EXTRA_ROUTE_HOME_AS_PARENT = "routeHomeAsParent"
     const val EXTRA_CACHE_GROUP_ID = "extra_cache_group_id"
     const val EXTRA_SEARCH_KEY = "extra_search_key"
     const val EXTRA_SEARCH_SCOPE = "extra_search_scope"
@@ -213,6 +214,21 @@ object MainIntent {
         }
     }
 
+    fun createReadBookMediaControlIntent(context: Context): Intent =
+        createReadBookIntent(context, readAloud = true).apply {
+            // 复用 launcher Activity 并移除其上层的旧 Activity;Nav3 父栈另行重建,
+            // 让阅读页返回时始终落到主页。
+            addFlags(
+                Intent.FLAG_ACTIVITY_NEW_TASK or
+                    Intent.FLAG_ACTIVITY_CLEAR_TOP or
+                    Intent.FLAG_ACTIVITY_SINGLE_TOP
+            )
+            putExtra(EXTRA_ROUTE_HOME_AS_PARENT, true)
+        }
+
+    internal fun shouldOpenRouteWithHomeParent(intent: Intent?): Boolean =
+        intent?.getBooleanExtra(EXTRA_ROUTE_HOME_AS_PARENT, false) == true
+
     fun createReadMangaIntent(
         context: Context,
         bookUrl: String? = null,

+ 9 - 1
app/src/main/java/io/legado/app/ui/main/MainNavigator.kt

@@ -22,7 +22,15 @@ object MainNavigator {
     }
     private var backNavigationResetJob: Job? = null
 
-    fun navigateToRoute(backStack: MutableList<NavKey>, route: NavKey) {
+    fun navigateToRoute(
+        backStack: MutableList<NavKey>,
+        route: NavKey,
+        resetToHome: Boolean = false,
+    ) {
+        if (resetToHome) {
+            backStack.clear()
+            backStack.add(MainRouteHome)
+        }
         val currentRoute = backStack.lastOrNull()
         if (currentRoute == route) return
 

+ 1 - 1
app/src/main/java/io/legado/app/ui/widget/components/changeSource/ChangeSourceSheet.kt

@@ -214,7 +214,7 @@ fun ChangeSourceSheet(
     AppModalBottomSheet(
         show = show,
         onDismissRequest = onDismissRequest,
-        title = stringResource(R.string.book_source),
+        title = stringResource(R.string.change_origin),
         startAction = {
             Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
                 Box {

+ 48 - 0
app/src/test/java/io/legado/app/ui/main/MainIntentTest.kt

@@ -0,0 +1,48 @@
+package io.legado.app.ui.main
+
+import android.app.Application
+import android.content.Intent
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+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 MainIntentTest {
+
+    @Test
+    fun `media control reader reuses task and requests home parent`() {
+        val context: Application = RuntimeEnvironment.getApplication()
+
+        val intent = MainIntent.createReadBookMediaControlIntent(context)
+
+        val expectedFlags = Intent.FLAG_ACTIVITY_NEW_TASK or
+            Intent.FLAG_ACTIVITY_CLEAR_TOP or
+            Intent.FLAG_ACTIVITY_SINGLE_TOP
+        assertEquals(expectedFlags, intent.flags and expectedFlags)
+        assertEquals(
+            MainRouteConst.ROUTE_READ_BOOK,
+            intent.getStringExtra(MainIntent.EXTRA_START_ROUTE),
+        )
+        assertTrue(intent.getBooleanExtra(MainIntent.EXTRA_READ_ALOUD, false))
+        assertTrue(MainIntent.shouldOpenRouteWithHomeParent(intent))
+    }
+
+    @Test
+    fun `regular reader keeps existing activity and navigation semantics`() {
+        val context: Application = RuntimeEnvironment.getApplication()
+
+        val intent = MainIntent.createReadBookIntent(context, readAloud = true)
+
+        val mediaControlFlags = Intent.FLAG_ACTIVITY_NEW_TASK or
+            Intent.FLAG_ACTIVITY_CLEAR_TOP or
+            Intent.FLAG_ACTIVITY_SINGLE_TOP
+        assertEquals(0, intent.flags and mediaControlFlags)
+        assertFalse(MainIntent.shouldOpenRouteWithHomeParent(intent))
+    }
+}

+ 22 - 0
app/src/test/java/io/legado/app/ui/main/MainNavigatorBackStackTest.kt

@@ -27,4 +27,26 @@ class MainNavigatorBackStackTest {
 
         assertEquals(listOf(MainRouteHome, manage, search), backStack)
     }
+
+    @Test
+    fun `media reader replaces stale route with home parent`() {
+        val bookInfo = MainRouteBookInfo("Book", "Author", "book-url")
+        val reader = MainRouteReadBook(readAloud = true)
+        val backStack = mutableListOf<NavKey>(MainRouteHome, bookInfo)
+
+        MainNavigator.navigateToRoute(backStack, reader, resetToHome = true)
+
+        assertEquals(listOf(MainRouteHome, reader), backStack)
+    }
+
+    @Test
+    fun `regular reader keeps book info as parent`() {
+        val bookInfo = MainRouteBookInfo("Book", "Author", "book-url")
+        val reader = MainRouteReadBook(bookUrl = "book-url", chapterChanged = true)
+        val backStack = mutableListOf<NavKey>(MainRouteHome, bookInfo)
+
+        MainNavigator.navigateToRoute(backStack, reader)
+
+        assertEquals(listOf(MainRouteHome, bookInfo, reader), backStack)
+    }
 }