[M3][ModalBottomSheet] Update internal popup logic to use custom abstract view

This enables use of custom window flags

Bug: 276339374
Test: Updated unit tests to account for discrepencies in system measurements and app measurements.
Change-Id: I353eb3d715e0bcb733e04b2291ba603c15be566b
diff --git a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/ModalBottomSheetTest.kt b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/ModalBottomSheetTest.kt
index 72643ec..cbbc054 100644
--- a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/ModalBottomSheetTest.kt
+++ b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/ModalBottomSheetTest.kt
@@ -213,8 +213,11 @@
     @Test
     fun modalBottomSheet_defaultStateForSmallContentIsFullExpanded() {
         lateinit var sheetState: SheetState
+        var height by mutableStateOf(0.dp)
 
         rule.setContent {
+            val config = LocalContext.current.resources.configuration
+            height = config.screenHeightDp.dp
             sheetState = rememberModalBottomSheetState()
             ModalBottomSheet(onDismissRequest = {}, sheetState = sheetState, dragHandle = null) {
                 Box(
@@ -226,7 +229,6 @@
             }
         }
 
-        val height = rule.onNode(isPopup()).getUnclippedBoundsInRoot().height
         assertThat(sheetState.currentValue).isEqualTo(SheetValue.Expanded)
         rule.onNodeWithTag(sheetTag).assertTopPositionInRootIsEqualTo(height - sheetHeight)
     }
@@ -356,15 +358,15 @@
                 )
             }
         }
-        assertThat(state.requireOffset()).isWithin(0.5f).of(expectedExpandedAnchor)
+        assertThat(state.requireOffset()).isWithin(1f).of(expectedExpandedAnchor)
 
         size = 100.dp
         rule.waitForIdle()
-        assertThat(state.requireOffset()).isWithin(0.5f).of(expectedExpandedAnchor)
+        assertThat(state.requireOffset()).isWithin(1f).of(expectedExpandedAnchor)
 
         size = 30.dp
         rule.waitForIdle()
-        assertThat(state.requireOffset()).isWithin(0.5f).of(expectedExpandedAnchor)
+        assertThat(state.requireOffset()).isWithin(1f).of(expectedExpandedAnchor)
     }
 
     @Test
diff --git a/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/ModalBottomSheet.android.kt b/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/ModalBottomSheet.android.kt
index 33e9f0c..9ba0d94 100644
--- a/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/ModalBottomSheet.android.kt
+++ b/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/ModalBottomSheet.android.kt
@@ -16,6 +16,13 @@
 
 package androidx.compose.material3
 
+import android.content.Context
+import android.graphics.PixelFormat
+import android.view.Gravity
+import android.view.KeyEvent
+import android.view.View
+import android.view.ViewTreeObserver
+import android.view.WindowManager
 import androidx.compose.animation.core.TweenSpec
 import androidx.compose.animation.core.animateFloatAsState
 import androidx.compose.foundation.Canvas
@@ -23,21 +30,27 @@
 import androidx.compose.foundation.gestures.detectTapGestures
 import androidx.compose.foundation.gestures.draggable
 import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.BoxWithConstraints
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.ColumnScope
-import androidx.compose.foundation.layout.WindowInsets
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.offset
 import androidx.compose.foundation.layout.widthIn
 import androidx.compose.material3.SheetValue.Expanded
 import androidx.compose.material3.SheetValue.Hidden
+import androidx.compose.material3.SheetValue.PartiallyExpanded
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionContext
+import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCompositionContext
 import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
@@ -45,17 +58,30 @@
 import androidx.compose.ui.graphics.isSpecified
 import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.AbstractComposeView
+import androidx.compose.ui.platform.LocalConfiguration
 import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.platform.ViewRootForInspector
 import androidx.compose.ui.semantics.clearAndSetSemantics
 import androidx.compose.ui.semantics.collapse
 import androidx.compose.ui.semantics.dismiss
 import androidx.compose.ui.semantics.expand
+import androidx.compose.ui.semantics.paneTitle
+import androidx.compose.ui.semantics.popup
 import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.IntOffset
-import androidx.compose.ui.window.Popup
-import androidx.compose.ui.window.PopupProperties
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.findViewTreeLifecycleOwner
+import androidx.lifecycle.findViewTreeViewModelStoreOwner
+import androidx.lifecycle.setViewTreeLifecycleOwner
+import androidx.lifecycle.setViewTreeViewModelStoreOwner
+import androidx.savedstate.findViewTreeSavedStateRegistryOwner
+import androidx.savedstate.setViewTreeSavedStateRegistryOwner
+import java.util.UUID
 import kotlin.math.max
+import kotlin.math.roundToInt
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.launch
 
@@ -102,6 +128,9 @@
     dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
     content: @Composable ColumnScope.() -> Unit,
 ) {
+    val configuration = LocalConfiguration.current
+    val density = LocalDensity.current
+    val fullHeight = with(density) { configuration.screenHeightDp.dp.toPx() }
     val scope = rememberCoroutineScope()
     val animateToDismiss: () -> Unit = {
         if (sheetState.swipeableState.confirmValueChange(Hidden)) {
@@ -128,7 +157,6 @@
             snapTo = { target -> scope.launch { sheetState.snapTo(target) } }
         )
     }
-    val systemBarHeight = WindowInsets.systemBarsForVisualComponents.getBottom(LocalDensity.current)
 
     ModalBottomSheetPopup(
         onDismissRequest = {
@@ -139,18 +167,19 @@
             }
         }
     ) {
-        BoxWithConstraints(Modifier.fillMaxSize()) {
-            val fullHeight = constraints.maxHeight
+        Box {
             Scrim(
                 color = scrimColor,
                 onDismissRequest = animateToDismiss,
                 visible = sheetState.targetValue != Hidden
             )
+            val bottomSheetPaneTitle = getString(string = Strings.BottomSheetPaneTitle)
             Surface(
                 modifier = modifier
                     .widthIn(max = BottomSheetMaxWidth)
                     .fillMaxWidth()
                     .align(Alignment.TopCenter)
+                    .semantics { paneTitle = bottomSheetPaneTitle }
                     .offset {
                         IntOffset(
                             0,
@@ -171,8 +200,7 @@
                     .modalBottomSheetSwipeable(
                         sheetState = sheetState,
                         anchorChangeHandler = anchorChangeHandler,
-                        screenHeight = fullHeight.toFloat(),
-                        bottomPadding = systemBarHeight.toFloat(),
+                        screenHeight = fullHeight,
                         onDragStopped = {
                             settleToDismiss(it)
                         },
@@ -188,35 +216,38 @@
                             getString(Strings.BottomSheetPartialExpandDescription)
                         val dismissActionLabel = getString(Strings.BottomSheetDismissDescription)
                         val expandActionLabel = getString(Strings.BottomSheetExpandDescription)
-                        Box(Modifier
-                            .align(Alignment.CenterHorizontally)
-                            .semantics(mergeDescendants = true) {
-                                // Provides semantics to interact with the bottomsheet based on its
-                                // current value.
-                                with(sheetState) {
-                                    dismiss(dismissActionLabel) {
-                                        animateToDismiss()
-                                        true
-                                    }
-                                    if (currentValue == SheetValue.PartiallyExpanded) {
-                                        expand(expandActionLabel) {
-                                            if (swipeableState.confirmValueChange(Expanded)) {
-                                                scope.launch { sheetState.expand() }
-                                            }
+                        Box(
+                            Modifier
+                                .align(Alignment.CenterHorizontally)
+                                .semantics(mergeDescendants = true) {
+                                    // Provides semantics to interact with the bottomsheet based on its
+                                    // current value.
+                                    with(sheetState) {
+                                        dismiss(dismissActionLabel) {
+                                            animateToDismiss()
                                             true
                                         }
-                                    } else if (hasPartiallyExpandedState) {
-                                        collapse(collapseActionLabel) {
-                                            val confirmPartial = swipeableState
-                                                .confirmValueChange(SheetValue.PartiallyExpanded)
-                                            if (confirmPartial) {
-                                                scope.launch { partialExpand() }
+                                        if (currentValue == PartiallyExpanded) {
+                                            expand(expandActionLabel) {
+                                                if (swipeableState.confirmValueChange(Expanded)) {
+                                                    scope.launch { sheetState.expand() }
+                                                }
+                                                true
                                             }
-                                            true
+                                        } else if (hasPartiallyExpandedState) {
+                                            collapse(collapseActionLabel) {
+                                                if (
+                                                    swipeableState.confirmValueChange(
+                                                        PartiallyExpanded
+                                                    )
+                                                ) {
+                                                    scope.launch { partialExpand() }
+                                                }
+                                                true
+                                            }
                                         }
                                     }
                                 }
-                            }
                         ) {
                             dragHandle()
                         }
@@ -265,7 +296,8 @@
                     detectTapGestures {
                         onDismissRequest()
                     }
-                }.clearAndSetSemantics {}
+                }
+                .clearAndSetSemantics {}
         } else {
             Modifier
         }
@@ -284,23 +316,22 @@
     sheetState: SheetState,
     anchorChangeHandler: AnchorChangeHandler<SheetValue>,
     screenHeight: Float,
-    bottomPadding: Float,
     onDragStopped: CoroutineScope.(velocity: Float) -> Unit,
 ) = draggable(
-    state = sheetState.swipeableState.swipeDraggableState,
-    orientation = Orientation.Vertical,
-    enabled = sheetState.isVisible,
-    startDragImmediately = sheetState.swipeableState.isAnimationRunning,
-    onDragStopped = onDragStopped
-)
+        state = sheetState.swipeableState.swipeDraggableState,
+        orientation = Orientation.Vertical,
+        enabled = sheetState.isVisible,
+        startDragImmediately = sheetState.swipeableState.isAnimationRunning,
+        onDragStopped = onDragStopped
+    )
     .swipeAnchors(
         state = sheetState.swipeableState,
         anchorChangeHandler = anchorChangeHandler,
-        possibleValues = setOf(Hidden, SheetValue.PartiallyExpanded, Expanded),
+        possibleValues = setOf(Hidden, PartiallyExpanded, Expanded),
     ) { value, sheetSize ->
         when (value) {
-            Hidden -> screenHeight + bottomPadding
-            SheetValue.PartiallyExpanded -> when {
+            Hidden -> screenHeight
+            PartiallyExpanded -> when {
                 sheetSize.height < screenHeight / 2 -> null
                 sheetState.skipPartiallyExpanded -> null
                 else -> screenHeight / 2f
@@ -320,9 +351,9 @@
     val previousTargetOffset = previousAnchors[previousTarget]
     val newTarget = when (previousTarget) {
         Hidden -> Hidden
-        SheetValue.PartiallyExpanded, Expanded -> {
-            val hasPartiallyExpandedState = newAnchors.containsKey(SheetValue.PartiallyExpanded)
-            val newTarget = if (hasPartiallyExpandedState) SheetValue.PartiallyExpanded
+        PartiallyExpanded, Expanded -> {
+            val hasPartiallyExpandedState = newAnchors.containsKey(PartiallyExpanded)
+            val newTarget = if (hasPartiallyExpandedState) PartiallyExpanded
             else if (newAnchors.containsKey(Expanded)) Expanded else Hidden
             newTarget
         }
@@ -343,12 +374,150 @@
  * Popup specific for modal bottom sheet.
  */
 @Composable
-@ExperimentalMaterial3Api
 internal fun ModalBottomSheetPopup(
     onDismissRequest: () -> Unit,
     content: @Composable () -> Unit
-) = Popup(
-    onDismissRequest = onDismissRequest,
-    properties = PopupProperties(focusable = true),
-    content = content
-)
\ No newline at end of file
+) {
+    val view = LocalView.current
+    val id = rememberSaveable { UUID.randomUUID() }
+    val parentComposition = rememberCompositionContext()
+    val currentContent by rememberUpdatedState(content)
+    val modalBottomSheetWindow = remember {
+        ModalBottomSheetWindow(
+            onDismissRequest = onDismissRequest,
+            composeView = view,
+            saveId = id
+        ).apply {
+            setCustomContent(
+                parent = parentComposition,
+                content = {
+                    Box(Modifier.semantics { this.popup() }) {
+                        currentContent()
+                    }
+                }
+            )
+        }
+    }
+
+    DisposableEffect(modalBottomSheetWindow) {
+        modalBottomSheetWindow.show()
+        onDispose {
+            modalBottomSheetWindow.disposeComposition()
+            modalBottomSheetWindow.dismiss()
+        }
+    }
+}
+
+/** Custom compose view for [ModalBottomSheet] */
+private class ModalBottomSheetWindow(
+    private var onDismissRequest: () -> Unit,
+    private val composeView: View,
+    saveId: UUID,
+) :
+    AbstractComposeView(composeView.context),
+    ViewTreeObserver.OnGlobalLayoutListener,
+    ViewRootForInspector {
+    init {
+        id = android.R.id.content
+        // Set up view owners
+        setViewTreeLifecycleOwner(composeView.findViewTreeLifecycleOwner())
+        setViewTreeViewModelStoreOwner(composeView.findViewTreeViewModelStoreOwner())
+        setViewTreeSavedStateRegistryOwner(composeView.findViewTreeSavedStateRegistryOwner())
+        setTag(androidx.compose.ui.R.id.compose_view_saveable_id_tag, "Popup:$saveId")
+        // Enable children to draw their shadow by not clipping them
+        clipChildren = false
+    }
+
+    private val windowManager =
+        composeView.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
+
+    private val displayWidth: Int
+        get() {
+            val density = context.resources.displayMetrics.density
+            return (context.resources.configuration.screenWidthDp * density).roundToInt()
+        }
+
+    private val displayHeight: Int
+        get() {
+            val density = context.resources.displayMetrics.density
+            return (context.resources.configuration.screenHeightDp * density).roundToInt()
+        }
+
+    private val params: WindowManager.LayoutParams =
+        WindowManager.LayoutParams().apply {
+            // Position bottom sheet from the bottom of the screen
+            gravity = Gravity.BOTTOM or Gravity.START
+            // Application panel window
+            type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL
+            // Fill up the entire app view
+            width = displayWidth
+            height = displayHeight
+
+            // Format of screen pixels
+            format = PixelFormat.TRANSLUCENT
+            // Title used as fallback for a11y services
+            // TODO: Provide bottom sheet window resource
+            title = composeView.context.resources.getString(
+                androidx.compose.ui.R.string.default_popup_window_title
+            )
+            // Get the Window token from the parent view
+            token = composeView.applicationWindowToken
+        }
+
+    private var content: @Composable () -> Unit by mutableStateOf({})
+
+    override var shouldCreateCompositionOnAttachedToWindow: Boolean = false
+        private set
+
+    @Composable
+    override fun Content() {
+        content()
+    }
+
+    fun setCustomContent(
+        parent: CompositionContext? = null,
+        content: @Composable () -> Unit
+    ) {
+        parent?.let { setParentCompositionContext(it) }
+        this.content = content
+        shouldCreateCompositionOnAttachedToWindow = true
+    }
+
+    fun show() {
+        windowManager.addView(this, params)
+    }
+
+    fun dismiss() {
+        setViewTreeLifecycleOwner(null)
+        setViewTreeSavedStateRegistryOwner(null)
+        composeView.viewTreeObserver.removeOnGlobalLayoutListener(this)
+        windowManager.removeViewImmediate(this)
+    }
+
+    /**
+     * Taken from PopupWindow. Calls [onDismissRequest] when back button is pressed.
+     */
+    override fun dispatchKeyEvent(event: KeyEvent): Boolean {
+        if (event.keyCode == KeyEvent.KEYCODE_BACK) {
+            if (keyDispatcherState == null) {
+                return super.dispatchKeyEvent(event)
+            }
+            if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
+                val state = keyDispatcherState
+                state?.startTracking(event, this)
+                return true
+            } else if (event.action == KeyEvent.ACTION_UP) {
+                val state = keyDispatcherState
+                if (state != null && state.isTracking(event) && !event.isCanceled) {
+                    onDismissRequest()
+                    return true
+                }
+            }
+        }
+        return super.dispatchKeyEvent(event)
+    }
+
+    override fun onGlobalLayout() {
+        // No-op
+    }
+}
diff --git a/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/Strings.android.kt b/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/Strings.android.kt
index 6a6c8c3..a9443a6 100644
--- a/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/Strings.android.kt
+++ b/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/Strings.android.kt
@@ -155,6 +155,9 @@
         Strings.DateRangeInputInvalidRangeInput -> resources.getString(
             androidx.compose.material3.R.string.m3c_date_range_input_invalid_range_input
         )
+        Strings.BottomSheetPaneTitle -> resources.getString(
+            androidx.compose.material3.R.string.bottom_sheet_pane_title
+        )
         Strings.BottomSheetDragHandleDescription -> resources.getString(
             androidx.compose.material3.R.string.m3c_bottom_sheet_drag_handle_description
         )
diff --git a/compose/material3/material3/src/androidMain/res/values/strings.xml b/compose/material3/material3/src/androidMain/res/values/strings.xml
index a2c68b0..063958d 100644
--- a/compose/material3/material3/src/androidMain/res/values/strings.xml
+++ b/compose/material3/material3/src/androidMain/res/values/strings.xml
@@ -72,6 +72,8 @@
     Describes an invalid date range input when a user enters a start or end date [CHAR_LIMIT=NONE]
     -->
     <string name="m3c_date_range_input_invalid_range_input">Invalid date range input</string>
+    <!-- Spoken description of a bottom sheet -->
+    <string name="m3c_bottom_sheet_pane_title">Bottom Sheet</string>
     <!-- Names the drag handle visual for bottom sheet. -->
     <string name="m3c_bottom_sheet_drag_handle_description">Drag handle</string>
     <!-- Describes the collapse action for bottom sheet. -->
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Strings.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Strings.kt
index dd8f671..285f8b4 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Strings.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Strings.kt
@@ -73,6 +73,7 @@
         val DateRangePickerDayInRange = Strings()
         val DateRangeInputTitle = Strings()
         val DateRangeInputInvalidRangeInput = Strings()
+        val BottomSheetPaneTitle = Strings()
         val BottomSheetDragHandleDescription = Strings()
         val BottomSheetPartialExpandDescription = Strings()
         val BottomSheetDismissDescription = Strings()
diff --git a/compose/material3/material3/src/desktopMain/kotlin/androidx/compose/material3/Strings.desktop.kt b/compose/material3/material3/src/desktopMain/kotlin/androidx/compose/material3/Strings.desktop.kt
index ed3cf456..81c26dd 100644
--- a/compose/material3/material3/src/desktopMain/kotlin/androidx/compose/material3/Strings.desktop.kt
+++ b/compose/material3/material3/src/desktopMain/kotlin/androidx/compose/material3/Strings.desktop.kt
@@ -68,6 +68,7 @@
         Strings.DateRangePickerDayInRange -> "In range"
         Strings.DateRangeInputTitle -> "Enter dates"
         Strings.DateRangeInputInvalidRangeInput -> "Invalid date range input"
+        Strings.BottomSheetPaneTitle -> "Bottom Sheet"
         Strings.BottomSheetDragHandleDescription -> "Drag Handle"
         Strings.BottomSheetPartialExpandDescription -> "Collapse bottom sheet"
         Strings.BottomSheetDismissDescription -> "Dismiss bottom sheet"