HapeLee 1 месяц назад
Родитель
Сommit
9847e035d4
19 измененных файлов с 610 добавлено и 87 удалено
  1. 23 7
      app/src/main/java/io/legado/app/core/ui/morph/BookCoverMorphAnchors.kt
  2. 23 11
      app/src/main/java/io/legado/app/core/ui/morph/BookMorphHost.kt
  3. 2 1
      app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt
  4. 14 8
      app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt
  5. 23 8
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderRouteScreen.kt
  6. 4 2
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderScreen.kt
  7. 1 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt
  8. 47 21
      app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt
  9. 5 2
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  10. 17 0
      app/src/main/java/io/legado/app/ui/main/BookCoverSharedElement.kt
  11. 4 2
      app/src/main/java/io/legado/app/ui/main/MainActivity.kt
  12. 49 10
      app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt
  13. 12 10
      app/src/main/java/io/legado/app/ui/main/MainNavigator.kt
  14. 16 2
      app/src/main/java/io/legado/app/ui/main/ModalOverlaySceneStrategy.kt
  15. 4 3
      app/src/main/java/io/legado/app/ui/main/PlaybackPlayerAnchors.kt
  16. 127 0
      app/src/test/java/io/legado/app/core/ui/morph/BookMorphHostInteractionTest.kt
  17. 186 0
      app/src/test/java/io/legado/app/ui/main/ModalOverlayBackInteractionTest.kt
  18. 52 0
      app/src/test/java/io/legado/app/ui/main/ModalOverlaySceneStrategyTest.kt
  19. 1 0
      app/src/test/java/io/legado/app/ui/main/PlaybackPlayerAnchorsTest.kt

+ 23 - 7
app/src/main/java/io/legado/app/core/ui/morph/BookCoverMorphAnchors.kt

@@ -51,13 +51,25 @@ object BookCoverMorphAnchors {
         val active = activeMorphKey ?: return false
         if (key.isNullOrBlank()) return false
         if (key == active) return true
-        val cleanActive = active.removePrefix("book-cover:")
-        val cleanKey = key.removePrefix("book-cover:")
-        return cleanActive == cleanKey ||
-                cleanActive.endsWith(":$cleanKey") ||
-                cleanKey.endsWith(":$cleanActive")
+        val isDetailActive = isDetailKey(active)
+        val isDetailCurrent = isDetailKey(key)
+        if (isDetailActive != isDetailCurrent) return false
+        val cleanActive = cleanKey(active)
+        val cleanCurrent = cleanKey(key)
+        return cleanActive == cleanCurrent ||
+                cleanActive.endsWith(":$cleanCurrent") ||
+                cleanCurrent.endsWith(":$cleanActive")
     }
 
+    private fun isDetailKey(key: String): Boolean =
+        key.startsWith("cover:detail:") || key.startsWith("book-info-cover:")
+
+    private fun cleanKey(key: String): String =
+        key.removePrefix("cover:shelf:")
+            .removePrefix("cover:detail:")
+            .removePrefix("book-info-cover:")
+            .removePrefix("book-cover:")
+
     /**
      * 判断指定书源/书架封面当前是否应当隐藏(让位给飞行封面)。
      *
@@ -109,8 +121,12 @@ object BookCoverMorphAnchors {
         if (key.isNullOrBlank()) return null
         val direct = anchors[key]
         if (direct != null) return direct
-        // 兜底策略:若键名包含 bookUrl,允许部分匹配
-        return anchors.entries.firstOrNull { it.key.contains(key) || key.contains(it.key) }?.value
+        val isDetail = isDetailKey(key)
+        // 兜底策略:只在同一命名空间内允许部分匹配
+        return anchors.entries.firstOrNull {
+            isDetailKey(it.key) == isDetail &&
+                    (it.key.contains(key) || key.contains(it.key))
+        }?.value
     }
 
     fun remove(key: String?) {

+ 23 - 11
app/src/main/java/io/legado/app/core/ui/morph/BookMorphHost.kt

@@ -92,13 +92,15 @@ fun BookMorphHost(
     backEnabled: Boolean = true,
     predictiveBackEnabled: Boolean = true,
     hasTargetCover: Boolean = false,
-    onDismiss: () -> Unit,
+    onDismiss: () -> Boolean,
+    onBackRequested: (() -> Unit)? = null,
     content: @Composable (onCollapse: () -> Unit) -> Unit,
 ) {
     val view = LocalView.current
     val scope = rememberCoroutineScope()
     val morph = rememberBookMorphState(anchorKey, hasTargetCover)
     val currentDismiss by rememberUpdatedState(onDismiss)
+    val currentBackRequested by rememberUpdatedState(onBackRequested)
     val currentBackEnabled by rememberUpdatedState(backEnabled)
     var backSettleJob by remember { mutableStateOf<Job?>(null) }
 
@@ -118,14 +120,17 @@ fun BookMorphHost(
         }
     }
 
-    val collapse: () -> Unit = {
-        scope.launch {
-            if (!anchorKey.isNullOrBlank()) {
-                BookCoverMorphAnchors.setActiveMorph(anchorKey, morph)
+    val collapse: () -> Unit = remember(scope, morph, anchorKey) {
+        {
+            backSettleJob?.cancel()
+            backSettleJob = scope.launch {
+                if (!anchorKey.isNullOrBlank()) {
+                    BookCoverMorphAnchors.setActiveMorph(anchorKey, morph)
+                }
+                BookCoverMorphAnchors.get(anchorKey)?.let(morph::updateAnchor)
+                morph.animateTo(0f)
+                if (!currentDismiss()) morph.animateTo(1f)
             }
-            refreshAnchor()
-            morph.animateTo(0f)
-            currentDismiss()
         }
     }
 
@@ -171,9 +176,16 @@ fun BookMorphHost(
                 morph.progress.snapTo(preview)
             }
             if (!predictiveBackEnabled) morph.progress.stop()
-            backSettleJob = scope.launch {
-                morph.animateTo(0f, initialVelocity = releaseVelocity)
-                currentDismiss()
+            val requestBack = currentBackRequested
+            if (requestBack != null) {
+                // Reader business logic authorizes the exit through Finish. It then calls
+                // collapse; the animation completion must never issue another close request.
+                requestBack()
+            } else {
+                backSettleJob = scope.launch {
+                    morph.animateTo(0f, initialVelocity = releaseVelocity)
+                    if (!currentDismiss()) morph.animateTo(1f)
+                }
             }
         } catch (cancelled: CancellationException) {
             morph.onPredictiveBackCancel()

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

@@ -21,10 +21,11 @@ class BookInfoActivity : BaseComposeActivity() {
         BookInfoRouteScreen(
             bookUrl = intent.getStringExtra("bookUrl").orEmpty(),
             viewModel = viewModel,
-            onBack = { finishAfterTransition() },
+            onBack = { finishAfterTransition(); true },
             onFinish = { resultCode, afterTransition ->
                 resultCode?.let { setResult(it) }
                 if (afterTransition) finishAfterTransition() else finish()
+                true
             },
             onOpenSearch = { keyword ->
                 startActivity(MainActivity.createSearchIntent(this, key = keyword))

+ 14 - 8
app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt

@@ -37,6 +37,7 @@ import io.legado.app.ui.book.info.edit.BookInfoEditActivity
 import io.legado.app.ui.book.toc.TocActivityResult
 import io.legado.app.ui.login.SourceLoginJsExtensions
 import io.legado.app.ui.main.bookCoverSharedElementKey
+import io.legado.app.ui.main.bookInfoCoverSharedElementKey
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
 import io.legado.app.utils.RealPathUtil
@@ -62,8 +63,8 @@ fun BookInfoRouteScreen(
     origin: String? = null,
     coverPath: String? = null,
     viewModel: BookInfoViewModel,
-    onBack: () -> Unit,
-    onFinish: (resultCode: Int?, afterTransition: Boolean) -> Unit,
+    onBack: () -> Boolean = { true },
+    onFinish: (resultCode: Int?, afterTransition: Boolean) -> Boolean = { _, _ -> true },
     onOpenSearch: (String) -> Unit,
     onOpenBookSourceEdit: (String) -> Unit,
     onOpenSourceLogin: (String) -> Unit,
@@ -82,6 +83,7 @@ fun BookInfoRouteScreen(
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
     sharedCoverKey: String? = null,
+    isTopRoute: Boolean = true,
 ) {
     val context = LocalContext.current
     val activity = context as AppCompatActivity
@@ -95,7 +97,8 @@ fun BookInfoRouteScreen(
     val noPasswordHint = stringResource(R.string.private_content_no_password)
     var showSelectBooksDirSheet by remember { mutableStateOf(false) }
 
-    val canMorphBack = uiState.dialog == null &&
+    val canMorphBack = isTopRoute &&
+            uiState.dialog == null &&
             uiState.sheet == BookInfoSheet.None &&
             !showSelectBooksDirSheet &&
             !uiState.showAppLogSheet &&
@@ -106,10 +109,13 @@ fun BookInfoRouteScreen(
     var finishAfterTransition by remember { mutableStateOf(false) }
     var collapseRequested by remember { mutableStateOf<(() -> Unit)?>(null) }
 
-    val dismissBookInfo: () -> Unit = {
-        if (!isDismissed) {
-            isDismissed = true
-            onFinish(finishResultCode, finishAfterTransition)
+    val dismissBookInfo: () -> Boolean = {
+        if (isDismissed) {
+            true
+        } else {
+            onFinish(finishResultCode, finishAfterTransition).also { popped ->
+                if (popped) isDismissed = true
+            }
         }
     }
 
@@ -314,7 +320,7 @@ fun BookInfoRouteScreen(
             onBack = handleBack,
             sharedTransitionScope = null,
             animatedVisibilityScope = null,
-            sharedCoverKey = null,
+            sharedCoverKey = remember(bookUrl) { bookInfoCoverSharedElementKey(bookUrl) },
         )
     }
 }

+ 23 - 8
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderRouteScreen.kt

@@ -25,6 +25,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import dev.chrisbanes.haze.HazeState
 import io.legado.app.constant.BookType
 import io.legado.app.core.ui.morph.BookMorphHost
+import io.legado.app.core.ui.morph.LocalBookMorph
 import io.legado.app.model.SourceCallBack
 import io.legado.app.receiver.NetworkChangedListener
 import io.legado.app.ui.book.read.sheet.ReaderBookSheetRoute
@@ -50,7 +51,8 @@ fun MangaReaderRouteScreen(
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
     sharedCoverKey: String? = null,
-    onFinish: (bookshelfChanged: Boolean) -> Unit,
+    isTopRoute: Boolean = true,
+    onFinish: (bookshelfChanged: Boolean) -> Boolean,
     onOpenBookInfo: (name: String, author: String, bookUrl: String) -> Unit,
     onOpenSourceLogin: (sourceUrl: String) -> Unit,
     onOpenSourceEdit: (sourceUrl: String) -> Unit,
@@ -83,10 +85,15 @@ fun MangaReaderRouteScreen(
     var isDismissed by remember { mutableStateOf(false) }
     var bookshelfChangedResult by remember { mutableStateOf(false) }
     var collapseTrigger by remember { mutableStateOf<(() -> Unit)?>(null) }
-    val dismissManga: () -> Unit = {
-        if (!isDismissed) {
-            isDismissed = true
-            onFinish(bookshelfChangedResult)
+    val dismissManga: () -> Boolean = {
+        if (isDismissed) {
+            true
+        } else {
+            // Finish already means that adding/discarding the book succeeded. UI state
+            // may still contain the pre-add snapshot; it must not initiate another delete.
+            onFinish(bookshelfChangedResult).also { popped ->
+                if (popped) isDismissed = true
+            }
         }
     }
 
@@ -230,11 +237,13 @@ fun MangaReaderRouteScreen(
         }
     }
 
-    val canMorphBack = state.activeDialog == null &&
+    val canHandleBack = isTopRoute
+    val canMorphBack = canHandleBack &&
+            state.inBookshelf &&
+            state.activeDialog == null &&
             state.activeSheet == null &&
             state.settingsCategory == null &&
-            !state.menuVisible &&
-            !(!state.inBookshelf && state.confirmAddToShelf)
+            !state.menuVisible
 
     BookMorphHost(
         anchorKey = sharedCoverKey,
@@ -242,7 +251,12 @@ fun MangaReaderRouteScreen(
         backEnabled = canMorphBack,
         predictiveBackEnabled = true,
         onDismiss = dismissManga,
+        onBackRequested = { viewModel.onIntent(MangaReaderIntent.BackPressed) },
     ) { onCollapse ->
+        val morph = LocalBookMorph.current
+        LaunchedEffect(state.activeDialog, morph) {
+            if (state.activeDialog != null) morph?.animateTo(1f)
+        }
         LaunchedEffect(onCollapse) {
             collapseTrigger = onCollapse
         }
@@ -256,6 +270,7 @@ fun MangaReaderRouteScreen(
             onIntent = viewModel::onIntent,
             hazeState = if (useMenuHaze) menuHazeState else null,
             modifier = Modifier.fillMaxSize(),
+            canHandleBack = canHandleBack,
         )
         if (state.activeSheet == MangaReaderSheet.Catalog && state.bookUrl.isNotEmpty()) {
             ReaderBookSheetRoute(

+ 4 - 2
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderScreen.kt

@@ -180,12 +180,14 @@ fun MangaReaderScreen(
     modifier: Modifier = Modifier,
     imageLoader: ImageLoader = koinInject(),
     hazeState: HazeState? = null,
+    canHandleBack: Boolean = true,
 ) {
-    val canMorphBack = state.activeDialog == null &&
+    val canMorphBack = state.inBookshelf &&
+            state.activeDialog == null &&
             state.activeSheet == null &&
             state.settingsCategory == null &&
             !state.menuVisible
-    BackHandler(enabled = !canMorphBack) { onIntent(MangaReaderIntent.BackPressed) }
+    BackHandler(enabled = canHandleBack && !canMorphBack) { onIntent(MangaReaderIntent.BackPressed) }
     var viewportSize by remember { mutableStateOf(IntSize.Zero) }
     var viewportOrigin by remember { mutableStateOf(Offset.Zero) }
     val aspectRatios = remember(state.bookUrl) { mutableStateMapOf<String, Float>() }

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

@@ -211,6 +211,7 @@ data class PendingBookmarkTarget(
 @Stable
 data class ReadBookUiState(
     val book: Book? = null,
+    val inBookshelf: Boolean = true,
     val bookSource: BookSource? = null,
     val bookName: String = "",
     val chapterName: String = "",

+ 47 - 21
app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt

@@ -71,6 +71,7 @@ import io.legado.app.constant.AppLog
 import io.legado.app.constant.BookType
 import io.legado.app.constant.ReadMenuBlurMode
 import io.legado.app.core.ui.morph.BookMorphHost
+import io.legado.app.core.ui.morph.LocalBookMorph
 import io.legado.app.feature.reader.ReaderBackgroundSurface
 import io.legado.app.feature.reader.ReaderCanvasSurface
 import io.legado.app.feature.reader.core.gesture.ReaderTapActionGrid
@@ -152,12 +153,13 @@ fun ReadBookRouteScreen(
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
     sharedCoverKey: String? = null,
+    isTopRoute: Boolean = true,
     onEffectsReady: () -> Unit = {},
     onOpenSearch: (word: String?, bookUrl: String, autoFocus: Boolean) -> Unit = { _, _, _ -> },
     onOpenVoiceCasting: (bookUrl: String) -> Unit = {},
     onOpenTtsEnginesAndVoices: () -> Unit = {},
     onOpenTtsCache: () -> Unit = {},
-    onNavigateBack: () -> Unit = {},
+    onNavigateBack: () -> Boolean = { true },
 ) {
     // 归因定界:与末尾 compose.screen.end 成对。若首帧 `Compose:recompose` 里出现
     // begin 之前的空档,说明那部分耗时在本屏之外(导航宿主 / 共享转场层)。
@@ -203,21 +205,45 @@ fun ReadBookRouteScreen(
                     !state.menuConfig.readMenuFloatingBottomBar &&
                             state.menuConfig.readMenuBottomBarBlurMode == ReadMenuBlurMode.LiquidGlass
                     )
-    val canMorphBack = state.activeSheet == null &&
+    val canHandleBack = isTopRoute
+    val canMorphBack = canHandleBack &&
+            state.inBookshelf &&
+            ReadBook.inBookshelf &&
+            state.activeSheet == null &&
             !state.isShowingSearchResult &&
             !state.isAutoPage &&
             !state.menuState.canNavigateBack &&
             state.activeDialog == null
 
-    BackHandler(enabled = !canMorphBack) {
+    var isDismissed by remember { mutableStateOf(false) }
+    var collapseTrigger by remember { mutableStateOf<(() -> Unit)?>(null) }
+
+    val performExit: () -> Boolean = {
+        if (isDismissed) {
+            true
+        } else {
+            onNavigateBack().also { popped ->
+                if (popped) isDismissed = true
+            }
+        }
+    }
+
+    val requestClose: () -> Unit = {
+        if (!isDismissed) {
+            viewModel.onIntent(ReadBookIntent.CloseReadBook())
+        }
+    }
+
+    BackHandler(enabled = canHandleBack && !canMorphBack) {
         when {
             state.activeSheet != null -> viewModel.onIntent(ReadBookIntent.DismissSheet)
             state.isShowingSearchResult -> viewModel.onIntent(ReadBookIntent.ExitSearch)
             state.isAutoPage -> viewModel.onIntent(ReadBookIntent.StopAutoPage)
             state.menuState.canNavigateBack -> viewModel.onIntent(ReadBookIntent.ReadMenuBack)
-            else -> viewModel.onIntent(ReadBookIntent.CloseReadBook())
+            else -> requestClose()
         }
     }
+
     DisposableEffect(controller) {
         controller.onComposeRendererAttached()
         onDispose {
@@ -513,6 +539,13 @@ fun ReadBookRouteScreen(
                                 exportHighlightRulePicker.launch("highlightRule.json")
                             }
 
+                            is ReadBookEffect.Finish -> {
+                                if (!isDismissed) {
+                                    val collapse = collapseTrigger
+                                    if (collapse != null) collapse() else performExit()
+                                }
+                            }
+
                             // All other effects — delegate to bridge (View/Window/Activity operations)
                             else -> controller.handleEffect(effect)
                         }
@@ -660,30 +693,23 @@ fun ReadBookRouteScreen(
             ReaderPerfTrace.marker("surface.page-ready")
         }
     }
-    var isDismissed by remember { mutableStateOf(false) }
-    var closingFromController by remember { mutableStateOf(false) }
-    val dismissReader: () -> Unit = {
-        if (!isDismissed) {
-            isDismissed = true
-            if (!closingFromController) {
-                viewModel.onIntent(ReadBookIntent.CloseReadBook())
-            }
-            onNavigateBack()
-        }
-    }
-
     BookMorphHost(
         anchorKey = sharedCoverKey,
         backgroundColor = readerSurfaceColor,
         backEnabled = canMorphBack,
         predictiveBackEnabled = true,
-        onDismiss = dismissReader,
+        onDismiss = performExit,
+        onBackRequested = requestClose,
     ) { onCollapse ->
+        val morph = LocalBookMorph.current
+        LaunchedEffect(state.activeDialog, morph) {
+            // Membership can change while a gesture is in progress. If the close request
+            // needs confirmation, restore the reader behind that dialog instead of exiting.
+            if (state.activeDialog != null) morph?.animateTo(1f)
+        }
         LaunchedEffect(onCollapse) {
-            controller.onClose = {
-                closingFromController = true
-                onCollapse()
-            }
+            collapseTrigger = onCollapse
+            controller.onClose = requestClose
         }
         Box(
             Modifier

+ 5 - 2
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -2128,7 +2128,10 @@ class ReadBookViewModel(
         request: ReadBookInitRequest,
         initialBook: Book? = null,
         success: (() -> Unit)? = null,
-    ) = loadDelegate.initData(request, initialBook, success)
+    ) {
+        _uiState.update { it.copy(inBookshelf = request.inBookshelf) }
+        loadDelegate.initData(request, initialBook, success)
+    }
 
     fun markJustInitData() {
         justInitData = true
@@ -2199,7 +2202,7 @@ class ReadBookViewModel(
             }
             ReadBook.inBookshelf = true
         }.onSuccess {
-            _uiState.update { it.copy(activeDialog = null) }
+            _uiState.update { it.copy(activeDialog = null, inBookshelf = true) }
             stopReadAloudForClose()
             _effects.tryEmit(ReadBookEffect.Finish)
         }.onError {

+ 17 - 0
app/src/main/java/io/legado/app/ui/main/BookCoverSharedElement.kt

@@ -1,6 +1,23 @@
 package io.legado.app.ui.main
 
+/**
+ * 图书形变动画封面键规范。
+ * 通过显式的作用域命名空间(shelf / detail)实现天然隔离,
+ * 杜绝字符串模糊匹配与不同层级间的锚点污染。
+ */
+object BookCoverSharedElement {
+    fun forShelf(bookUrl: String, sourceId: String? = null): String {
+        val source = sourceId?.takeIf { it.isNotBlank() } ?: return "cover:shelf:$bookUrl"
+        return "cover:shelf:$source:$bookUrl"
+    }
+
+    fun forDetail(bookUrl: String): String = "cover:detail:$bookUrl"
+}
+
 fun bookCoverSharedElementKey(bookUrl: String, sourceId: String? = null): String {
     val source = sourceId?.takeIf { it.isNotBlank() } ?: return "book-cover:$bookUrl"
     return "book-cover:$source:$bookUrl"
 }
+
+fun bookInfoCoverSharedElementKey(bookUrl: String): String =
+    BookCoverSharedElement.forDetail(bookUrl)

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

@@ -318,7 +318,7 @@ open class MainActivity : BaseComposeActivity(), AudioPlay.CallBack {
     private val mangaSettingsGateway by inject<MangaSettingsGateway>()
     private val backupSettingsGateway by inject<BackupSettingsGateway>()
     private val readAloudSettingsRepository by inject<ReadAloudSettingsRepository>()
-    private val navRouteTracker by inject<MainNavRouteTracker>()
+    internal val navRouteTracker by inject<MainNavRouteTracker>()
     private val routeEvents = MutableSharedFlow<RouteEvent>(extraBufferCapacity = 1)
     private val localNetworkPermissionLauncher = registerForActivityResult(
         ActivityResultContracts.RequestPermission()
@@ -800,7 +800,9 @@ open class MainActivity : BaseComposeActivity(), AudioPlay.CallBack {
                 }
                 BackHandler(
                     enabled = shouldHandleActivityBack(
-                        configuration.appShell.predictiveBackEnabled, playerVisible || morphPresent,
+                        predictiveBackEnabled = configuration.appShell.predictiveBackEnabled,
+                        playerPresent = playerVisible || morphPresent,
+                        isRoot = backStack.size <= 1,
                     )
                 ) {
                     MainNavigator.navigateBack(this@MainActivity, backStack)

+ 49 - 10
app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt

@@ -735,7 +735,12 @@ fun MainActivity.mainEntryProvider(
             bookUrl = route.bookUrl,
             onExit = {
                 if (backStack.size > 1) {
-                    onNavigateBack()
+                    MainNavigator.navigateBack(
+                        this@mainEntryProvider,
+                        backStack,
+                        navRouteTracker,
+                        fromRoute = route,
+                    )
                 } else {
                     // 通知 / 深链可能让阅读器成为栈里唯一一项:没有"当前页"可退,
                     // 按约定落到书架,而不是把应用关掉
@@ -817,6 +822,9 @@ fun MainActivity.mainEntryProvider(
                 sharedTransitionScope = sharedTransitionScope,
                 animatedVisibilityScope = LocalNavAnimatedContentScope.current,
                 sharedCoverKey = route.sharedCoverKey,
+                isTopRoute = (backStack.lastOrNull() as? MainRouteReadBook)?.let {
+                    route.bookUrl == null || it.bookUrl == route.bookUrl
+                } ?: false,
                 onEffectsReady = { effectsReady.complete(Unit) },
                 onOpenSearch = { word, bookUrl, autoFocus ->
                     onNavigateToRoute(
@@ -837,7 +845,14 @@ fun MainActivity.mainEntryProvider(
                 onOpenTtsCache = {
                     onNavigateToRoute(MainRouteTtsCache)
                 },
-                onNavigateBack = { onNavigateBack() },
+                onNavigateBack = {
+                    MainNavigator.navigateBack(
+                        this@mainEntryProvider,
+                        backStack,
+                        navRouteTracker,
+                        fromRoute = route,
+                    )
+                },
             )
 
             DisposableEffect(controller, lifecycleOwner, route.readAloud) {
@@ -873,6 +888,7 @@ fun MainActivity.mainEntryProvider(
                         activeReadBookRoute = null
                     }
                     MainActivity.hasActiveReadBookRoute = false
+                    controller.onClose = null
                     controller.clearTts()
                     this@mainEntryProvider.toggleSystemBar(configuration.appShell.showStatusBar)
                 }
@@ -911,7 +927,17 @@ fun MainActivity.mainEntryProvider(
             sharedTransitionScope = sharedTransitionScope,
             animatedVisibilityScope = LocalNavAnimatedContentScope.current,
             sharedCoverKey = route.sharedCoverKey,
-            onFinish = { onNavigateBack() },
+            isTopRoute = (backStack.lastOrNull() as? MainRouteReadManga)?.let {
+                route.bookUrl == null || it.bookUrl == route.bookUrl
+            } ?: false,
+            onFinish = {
+                MainNavigator.navigateBack(
+                    this@mainEntryProvider,
+                    backStack,
+                    navRouteTracker,
+                    fromRoute = route,
+                )
+            },
             onOpenBookInfo = { name, author, bookUrl ->
                 onNavigateToRoute(MainRouteBookInfo(name, author, bookUrl))
             },
@@ -1133,8 +1159,22 @@ fun MainActivity.mainEntryProvider(
             origin = route.origin,
             coverPath = route.coverPath,
             viewModel = bookInfoViewModel,
-            onBack = { onNavigateBack() },
-            onFinish = { _, _ -> onNavigateBack() },
+            onBack = {
+                MainNavigator.navigateBack(
+                    this@mainEntryProvider,
+                    backStack,
+                    navRouteTracker,
+                    fromRoute = route,
+                )
+            },
+            onFinish = { _, _ ->
+                MainNavigator.navigateBack(
+                    this@mainEntryProvider,
+                    backStack,
+                    navRouteTracker,
+                    fromRoute = route,
+                )
+            },
             onOpenSearch = { keyword ->
                 onNavigateToRoute(MainRouteSearch(key = keyword))
             },
@@ -1154,7 +1194,7 @@ fun MainActivity.mainEntryProvider(
                         bookUrl = bookUrl,
                         inBookshelf = inBookshelf,
                         chapterChanged = chapterChanged,
-                        sharedCoverKey = route.sharedCoverKey ?: bookCoverSharedElementKey(route.bookUrl),
+                        sharedCoverKey = bookInfoCoverSharedElementKey(bookUrl),
                     )
                 )
             },
@@ -1165,8 +1205,7 @@ fun MainActivity.mainEntryProvider(
                         inBookshelf = inBookshelf,
                         chapterChanged = chapterChanged,
                         openRequestId = System.nanoTime(),
-                        sharedCoverKey = route.sharedCoverKey
-                            ?: bookCoverSharedElementKey(route.bookUrl),
+                        sharedCoverKey = bookInfoCoverSharedElementKey(bookUrl),
                     )
                 )
             },
@@ -1175,8 +1214,7 @@ fun MainActivity.mainEntryProvider(
                     MainRouteAudioPlay(
                         bookUrl = bookUrl,
                         inBookshelf = inBookshelf,
-                        sharedCoverKey = route.sharedCoverKey
-                            ?: bookCoverSharedElementKey(route.bookUrl),
+                        sharedCoverKey = bookInfoCoverSharedElementKey(bookUrl),
                     )
                 )
             },
@@ -1204,6 +1242,7 @@ fun MainActivity.mainEntryProvider(
             sharedTransitionScope = sharedTransitionScope,
             animatedVisibilityScope = LocalNavAnimatedContentScope.current,
             sharedCoverKey = route.sharedCoverKey ?: bookCoverSharedElementKey(route.bookUrl),
+            isTopRoute = (backStack.lastOrNull() as? MainRouteBookInfo)?.bookUrl == route.bookUrl,
         )
     }
 

+ 12 - 10
app/src/main/java/io/legado/app/ui/main/MainNavigator.kt

@@ -335,32 +335,34 @@ object MainNavigator {
         tracker?.onBackStackChanged(backStack)
     }
 
-    fun navigateBack(activity: Activity, backStack: MutableList<NavKey>) {
-        navigateBack(activity, backStack, null)
-    }
-
-    /** [tracker] 非空时同步栈顶快照,供 Activity 级叠层立即重算显隐。 */
     fun navigateBack(
         activity: Activity,
         backStack: MutableList<NavKey>,
-        tracker: MainNavRouteTracker?,
-    ) {
-        if (backNavigationInProgress) {
-            return
+        tracker: MainNavRouteTracker? = null,
+        fromRoute: NavKey? = null,
+    ): Boolean {
+        if (fromRoute != null) {
+            if (backStack.lastOrNull() != fromRoute) {
+                return false
+            }
+        } else if (backNavigationInProgress) {
+            return false
         }
         if (backStack.size > 1) {
             backNavigationInProgress = true
             backStack.removeLastOrNull()
             tracker?.onBackStackChanged(backStack)
+            return true
         } else {
             activity.finish()
+            return true
         }
     }
 
     fun onBackStackChanged() {
         backNavigationResetJob?.cancel()
         backNavigationResetJob = navigationScope.launch {
-            delay(500)
+            delay(100)
             backNavigationInProgress = false
         }
     }

+ 16 - 2
app/src/main/java/io/legado/app/ui/main/ModalOverlaySceneStrategy.kt

@@ -1,6 +1,9 @@
 package io.legado.app.ui.main
 
+import androidx.compose.foundation.layout.Box
 import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.zIndex
 import androidx.navigation3.runtime.NavEntry
 import androidx.navigation3.runtime.NavKey
 import androidx.navigation3.runtime.NavMetadataKey
@@ -40,6 +43,17 @@ private data class ModalOverlayScene(
 ) : OverlayScene<NavKey> {
     override val key: Any = entry.contentKey
     override val entries: List<NavEntry<NavKey>> = listOf(entry)
-    override val overlaidEntries: List<NavEntry<NavKey>> = previousEntries.takeLast(1)
-    override val content: @Composable () -> Unit = { entry.Content() }
+
+    // NavDisplay recursively resolves the scene underneath an overlay. Passing only the
+    // last entry turns a nested overlay (book info under a reader) into a SinglePane root,
+    // changing its composition/lifecycle owner and discarding its parent scene.
+    override val overlaidEntries: List<NavEntry<NavKey>> = previousEntries
+    override val content: @Composable () -> Unit = {
+        // NavDisplay retains existing overlays and appends newly opened ones. Their
+        // composition order can therefore differ from stack order for nested overlays.
+        // Draw and hit-test by stack depth so a reader always sits above book info.
+        Box(Modifier.zIndex(previousEntries.size.toFloat())) {
+            entry.Content()
+        }
+    }
 }

+ 4 - 3
app/src/main/java/io/legado/app/ui/main/PlaybackPlayerAnchors.kt

@@ -19,9 +19,10 @@ internal fun shouldUseHomePlaybackCapsule(
     useRail: Boolean,
 ): Boolean = onMainRoute && showBottomView && useFloatingBottomBar && !useRail
 
-/** 播放浮层(包括进出场)优先处理返回,不能弹掉下层页面。 */
+/** 宿主只在真正的根页面(如主页)且未开启预测性返回时兜底处理退出,子页面由自身及导航层优先拦截。播放浮层优先处理返回。 */
 internal fun shouldHandleActivityBack(
     predictiveBackEnabled: Boolean,
-    playerPresent: Boolean
+    playerPresent: Boolean,
+    isRoot: Boolean = true,
 ): Boolean =
-    !predictiveBackEnabled && !playerPresent
+    !predictiveBackEnabled && !playerPresent && isRoot

+ 127 - 0
app/src/test/java/io/legado/app/core/ui/morph/BookMorphHostInteractionTest.kt

@@ -0,0 +1,127 @@
+package io.legado.app.core.ui.morph
+
+import android.app.Application
+import android.os.Looper
+import android.view.View
+import androidx.activity.BackEventCompat
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.graphics.Color
+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.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+import java.time.Duration
+
+@RunWith(RobolectricTestRunner::class)
+// Keep the Compose frame dispatcher separate from the Navigation 3 tests' API 34 sandbox.
+@Config(application = Application::class, sdk = [35])
+class BookMorphHostInteractionTest {
+    @Test
+    fun rejectedDismissRestoresContentForButtonAndGestureAndAllowsRetry() {
+        val controller = Robolectric.buildActivity(ComponentActivity::class.java).setup().visible()
+        val activity = controller.get()
+        var morph: BookMorphState? = null
+        var collapse: (() -> Unit)? = null
+        var attempts = 0
+        var allowPop = false
+        var requests = 0
+        var requestApproval by mutableStateOf(false)
+        try {
+            activity.setContent {
+                key(requestApproval) {
+                    BookMorphHost(
+                        anchorKey = null,
+                        backgroundColor = Color.White,
+                        onDismiss = { attempts++; allowPop },
+                        onBackRequested = if (requestApproval) ({ requests++ }) else null,
+                    ) { onCollapse ->
+                        val state = LocalBookMorph.current
+                        SideEffect { morph = state; collapse = onCollapse }
+                        BasicText("Reader")
+                    }
+                }
+            }
+            fun frames() {
+                repeat(90) {
+                    shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(32))
+                    val decor = activity.window.decorView
+                    decor.measure(
+                        View.MeasureSpec.makeMeasureSpec(1080, View.MeasureSpec.EXACTLY),
+                        View.MeasureSpec.makeMeasureSpec(1920, View.MeasureSpec.EXACTLY),
+                    )
+                    decor.layout(0, 0, 1080, 1920)
+                }
+            }
+
+            fun startGesture() {
+                activity.onBackPressedDispatcher.dispatchOnBackStarted(
+                    BackEventCompat(0f, 0f, 0f, BackEventCompat.EDGE_LEFT)
+                )
+                activity.onBackPressedDispatcher.dispatchOnBackProgressed(
+                    BackEventCompat(0f, 0f, 0.6f, BackEventCompat.EDGE_LEFT)
+                )
+            }
+            frames()
+            assertTrue(requireNotNull(morph).expanded)
+            requireNotNull(collapse).invoke()
+            frames()
+            assertEquals(1, attempts)
+            assertTrue(
+                "Rejected button dismissal must restore content",
+                requireNotNull(morph).expanded
+            )
+
+            startGesture()
+            activity.onBackPressedDispatcher.onBackPressed()
+            frames()
+            assertEquals(2, attempts)
+            assertTrue(
+                "Rejected predictive dismissal must restore content",
+                requireNotNull(morph).expanded
+            )
+
+            startGesture()
+            activity.onBackPressedDispatcher.dispatchOnBackCancelled()
+            frames()
+            assertEquals(2, attempts)
+            assertTrue(requireNotNull(morph).expanded)
+
+            allowPop = true
+            startGesture()
+            activity.onBackPressedDispatcher.onBackPressed()
+            frames()
+            assertEquals(3, attempts)
+            assertEquals(0f, requireNotNull(morph).progress.value)
+            assertFalse(activity.isFinishing)
+
+            // Readers request permission first. Finish invokes collapse separately, so
+            // final navigation neither repeats the close command nor runs book deletion.
+            requestApproval = true
+            frames()
+            startGesture()
+            activity.onBackPressedDispatcher.onBackPressed()
+            frames()
+            assertEquals(1, requests)
+            assertEquals("A back request must wait for Finish", 3, attempts)
+            requireNotNull(collapse).invoke()
+            frames()
+            assertEquals(4, attempts)
+            assertEquals("Committing Finish must not request close again", 1, requests)
+            assertEquals(0f, requireNotNull(morph).progress.value)
+        } finally {
+            controller.pause().stop().destroy()
+        }
+    }
+}

+ 186 - 0
app/src/test/java/io/legado/app/ui/main/ModalOverlayBackInteractionTest.kt

@@ -0,0 +1,186 @@
+package io.legado.app.ui.main
+
+import android.app.Application
+import android.os.Looper
+import android.view.MotionEvent
+import android.view.View
+import androidx.activity.BackEventCompat
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.PredictiveBackHandler
+import androidx.activity.compose.setContent
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.navigation3.runtime.NavKey
+import androidx.navigation3.runtime.entryProvider
+import androidx.navigation3.scene.SinglePaneSceneStrategy
+import androidx.navigation3.ui.NavDisplay
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertSame
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+import java.time.Duration
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [34])
+class ModalOverlayBackInteractionTest {
+    @Test
+    fun readerBackThenInfoPredictiveBackReturnsToEachEntryPointWithoutReplacingInfoLifecycleOwner() {
+        verifyBack(MainRouteHome)
+        verifyBack(MainRouteSearch(key = "Book"))
+        verifyBack(MainRouteExploreShow("Explore", "source-url", "explore-url"))
+    }
+
+    private fun verifyBack(parent: NavKey) {
+        val controller = Robolectric.buildActivity(ComponentActivity::class.java).setup().visible()
+        val activity = controller.get()
+        val info = MainRouteBookInfo("Book", "Author", "book-url")
+        val reader = MainRouteReadBook(bookUrl = "book-url")
+        val parentStack =
+            if (parent == MainRouteHome) listOf(parent) else listOf(MainRouteHome, parent)
+        val backStack = mutableStateListOf<NavKey>().apply { addAll(parentStack) }
+        MainNavigator.navigateToRoute(backStack, info)
+        assertEquals(parentStack + info, backStack.toList())
+        var infoOwner: LifecycleOwner? = null
+        val handledBacks = mutableListOf<NavKey>()
+        val tappedRoutes = mutableListOf<NavKey>()
+        try {
+            activity.setContent {
+                NavDisplay(
+                    backStack = backStack,
+                    sceneStrategies = listOf(
+                        ModalOverlaySceneStrategy(),
+                        SinglePaneSceneStrategy()
+                    ),
+                    transitionSpec = { EnterTransition.None togetherWith ExitTransition.None },
+                    popTransitionSpec = { EnterTransition.None togetherWith ExitTransition.None },
+                    predictivePopTransitionSpec = { EnterTransition.None togetherWith ExitTransition.None },
+                    onBack = { backStack.removeLastOrNull() },
+                    entryProvider = entryProvider {
+                        entry<MainRouteHome> { BasicText("Home") }
+                        entry<MainRouteSearch> { BasicText("Search") }
+                        entry<MainRouteExploreShow> { BasicText("Explore") }
+                        entry<MainRouteBookInfo>(metadata = ModalOverlaySceneStrategy.modalOverlay()) { route ->
+                            val owner = LocalLifecycleOwner.current
+                            SideEffect { infoOwner = owner }
+                            PredictiveBackHandler(enabled = backStack.lastOrNull() == route) { events ->
+                                events.collect { }
+                                handledBacks.add(route)
+                                backStack.removeLastOrNull()
+                            }
+                            Box(Modifier
+                                .fillMaxSize()
+                                .clickable { tappedRoutes.add(route) }) {
+                                BasicText("Info")
+                            }
+                        }
+                        entry<MainRouteReadBook>(metadata = ModalOverlaySceneStrategy.modalOverlay()) { route ->
+                            PredictiveBackHandler(enabled = backStack.lastOrNull() == route) { events ->
+                                events.collect { }
+                                handledBacks.add(route)
+                                backStack.removeLastOrNull()
+                            }
+                            Box(Modifier
+                                .fillMaxSize()
+                                .clickable { tappedRoutes.add(route) }) {
+                                BasicText("Reader")
+                            }
+                        }
+                    },
+                )
+            }
+            fun frames() {
+                repeat(30) {
+                    shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(32))
+                    val decor = activity.window.decorView
+                    decor.measure(
+                        View.MeasureSpec.makeMeasureSpec(1080, View.MeasureSpec.EXACTLY),
+                        View.MeasureSpec.makeMeasureSpec(1920, View.MeasureSpec.EXACTLY),
+                    )
+                    decor.layout(0, 0, 1080, 1920)
+                }
+            }
+
+            fun startGesture() {
+                activity.onBackPressedDispatcher.dispatchOnBackStarted(
+                    BackEventCompat(0f, 0f, 0f, BackEventCompat.EDGE_LEFT)
+                )
+                activity.onBackPressedDispatcher.dispatchOnBackProgressed(
+                    BackEventCompat(0f, 0f, 0.6f, BackEventCompat.EDGE_LEFT)
+                )
+                frames()
+            }
+
+            fun assertTopRoute(expected: NavKey) {
+                val decor = activity.window.decorView
+                val time = android.os.SystemClock.uptimeMillis()
+                val down = MotionEvent.obtain(time, time, MotionEvent.ACTION_DOWN, 540f, 960f, 0)
+                val up = MotionEvent.obtain(time, time + 16, MotionEvent.ACTION_UP, 540f, 960f, 0)
+                try {
+                    decor.dispatchTouchEvent(down)
+                    decor.dispatchTouchEvent(up)
+                } finally {
+                    down.recycle()
+                    up.recycle()
+                }
+                frames()
+                assertEquals(
+                    "The visible, tappable route must match the stack top",
+                    expected,
+                    tappedRoutes.lastOrNull()
+                )
+            }
+            frames()
+            assertTopRoute(info)
+            val originalInfoOwner = requireNotNull(infoOwner)
+            MainNavigator.navigateToRoute(backStack, reader)
+            assertEquals(parentStack + info + reader, backStack.toList())
+            frames()
+            assertSame(originalInfoOwner, infoOwner)
+            assertTopRoute(reader)
+            startGesture()
+            activity.onBackPressedDispatcher.onBackPressed()
+            frames()
+            assertEquals(parentStack + info, backStack.toList())
+            assertSame(originalInfoOwner, infoOwner)
+            assertTopRoute(info)
+            assertEquals(listOf(reader), handledBacks)
+
+            startGesture()
+            activity.onBackPressedDispatcher.dispatchOnBackCancelled()
+            frames()
+            assertEquals(parentStack + info, backStack.toList())
+            assertEquals(listOf(reader), handledBacks)
+            startGesture()
+            activity.onBackPressedDispatcher.onBackPressed()
+            frames()
+            assertEquals(parentStack, backStack.toList())
+            assertEquals(listOf(reader, info), handledBacks)
+            assertFalse(activity.isFinishing)
+            if (parent != MainRouteHome) {
+                // The restored Search/Explore scene must still have Home as its parent.
+                startGesture()
+                activity.onBackPressedDispatcher.onBackPressed()
+                frames()
+                assertEquals(listOf(MainRouteHome), backStack.toList())
+                assertFalse(activity.isFinishing)
+            }
+        } finally {
+            controller.pause().stop().destroy()
+        }
+    }
+}

+ 52 - 0
app/src/test/java/io/legado/app/ui/main/ModalOverlaySceneStrategyTest.kt

@@ -0,0 +1,52 @@
+package io.legado.app.ui.main
+
+import androidx.navigation3.runtime.NavEntry
+import androidx.navigation3.runtime.NavKey
+import androidx.navigation3.scene.OverlayScene
+import androidx.navigation3.scene.Scene
+import androidx.navigation3.scene.SceneStrategyScope
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class ModalOverlaySceneStrategyTest {
+    private val strategy = ModalOverlaySceneStrategy()
+    private val scope = SceneStrategyScope<NavKey>()
+
+    @Test
+    fun `reader keeps the same book info overlay and home parent`() {
+        val home = entry(MainRouteHome)
+        val info = entry(MainRouteBookInfo("Book", "Author", "book-url"), overlay = true)
+        val reader = entry(MainRouteReadBook(bookUrl = "book-url"), overlay = true)
+        val infoBeforeReading = calculate(listOf(home, info)) as OverlayScene<NavKey>
+        val readerScene = calculate(listOf(home, info, reader)) as OverlayScene<NavKey>
+
+        // NavDisplay recursively calculates the background from overlaidEntries.
+        // Keeping only info turns it into a SinglePane root and loses its overlay owner.
+        val infoUnderReader = calculate(readerScene.overlaidEntries)
+        assertNotNull("Book info must remain an overlay while reading", infoUnderReader)
+        assertEquals(infoBeforeReading, infoUnderReader)
+        assertEquals(listOf(home), (infoUnderReader as OverlayScene<NavKey>).previousEntries)
+
+        val infoAfterReading = calculate(readerScene.previousEntries)
+        assertEquals(infoBeforeReading, infoAfterReading)
+        assertEquals(listOf(home), infoAfterReading!!.previousEntries)
+    }
+
+    @Test
+    fun `root destination is never an overlay even with overlay metadata`() {
+        val info = entry(MainRouteBookInfo("Book", "Author", "book-url"), overlay = true)
+        assertNull(calculate(listOf(info)))
+        assertNull(calculate(listOf(entry(MainRouteHome))))
+    }
+
+    private fun calculate(entries: List<NavEntry<NavKey>>): Scene<NavKey>? =
+        with(strategy) { scope.calculateScene(entries) }
+
+    private fun entry(key: NavKey, overlay: Boolean = false): NavEntry<NavKey> =
+        NavEntry(
+            key = key,
+            metadata = if (overlay) ModalOverlaySceneStrategy.modalOverlay() else emptyMap(),
+        ) {}
+}

+ 1 - 0
app/src/test/java/io/legado/app/ui/main/PlaybackPlayerAnchorsTest.kt

@@ -64,5 +64,6 @@ class PlaybackPlayerAnchorsTest {
         assertFalse(shouldHandleActivityBack(true, playerPresent = true))
         assertTrue(shouldHandleActivityBack(false, playerPresent = false))
         assertFalse(shouldHandleActivityBack(true, playerPresent = false))
+        assertFalse(shouldHandleActivityBack(false, playerPresent = false, isRoot = false))
     }
 }