Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
e257b5b
Added support for UiMediaScope by creating PlatformMediaEnvironment, …
ApoloApps Jul 11, 2026
0836ca6
Forgotten imports for PlatformMediaEnvironment
ApoloApps Jul 11, 2026
66fbb7d
Added WebMediaEnvironment (support UiMediaScope in Web platforms)
ApoloApps Jul 11, 2026
c71d088
Delete KeyboardModeState as it is no longer used
ApoloApps Jul 11, 2026
3ed0607
Move Web ComposeWindowState to its own file for better separation of …
ApoloApps Jul 11, 2026
1038d75
Remove unused overloads in internal window file
ApoloApps Jul 11, 2026
26b7852
Cleanup initialization for PlatformContext so that it is only initial…
ApoloApps Jul 11, 2026
299b261
Move DefaultHapticFeedback to PlatformContext to follow the same patt…
ApoloApps Jul 11, 2026
cf14048
Achieve lazy ClipEventsTarget in a better way. Use providesComputed a…
ApoloApps Jul 11, 2026
eacfe25
Todo in WebMediaEnvironment
ApoloApps Jul 11, 2026
5d5e412
Cleanup imports
ApoloApps Jul 11, 2026
ac083db
Move variable to its correspondant editor fold
ApoloApps Jul 11, 2026
b302548
Web Test fixes
ApoloApps Jul 12, 2026
74c8d2d
Web Test fixes (Initialization error, userAgent was not init)
ApoloApps Jul 12, 2026
5a6e89a
Added UiMediaScope support for Jvm/Desktop target
ApoloApps Jul 12, 2026
c0b24b2
Added Dispose method in UiMediaScope support for Jvm/Desktop target
ApoloApps Jul 12, 2026
2900f74
Added support for UiMediaScope for iOS platform
ApoloApps Jul 12, 2026
73f45fe
Merge branch 'jb-main' into uiMediaSupport
ApoloApps Jul 13, 2026
64e79d7
Update WebWindowInsetsManager to make use of WebMediaEnvironment. Den…
ApoloApps Jul 13, 2026
05a4de0
Remove redundant UIWindow.density overload
ApoloApps Jul 13, 2026
a346f36
Rename IosMediaEnvironment to MediaEnvironment
ApoloApps Jul 13, 2026
d4271e1
Removed getViewWindowScene in favour of local uiWindow variable that …
ApoloApps Jul 13, 2026
6157886
Removed/Renamed dispose and initialization on iOS and remove it from …
ApoloApps Jul 13, 2026
e593bde
Use OptIn in place
ApoloApps Jul 13, 2026
bc757f3
Touches can come from Stylus in iOS platform, if any pointer is Stylu…
ApoloApps Jul 13, 2026
cf702f5
Added CaptureDeviceAvailability support to iOS MediaEnvironment by ad…
ApoloApps Jul 13, 2026
5a6f580
Merge branch 'jb-main' into uiMediaSupport
ApoloApps Jul 14, 2026
62c0a8e
Merge branch 'JetBrains:jb-main' into uiMediaSupport
ApoloApps Jul 14, 2026
10c8213
rename file to MediaEnvironment.ios.kt as well
ApoloApps Jul 14, 2026
a3b2c12
Fixes tests by adding a removed import that was necessary in ComposeS…
ApoloApps Jul 14, 2026
599abc5
Make MediaEnvironment implement KeyboardVisibilityObserver and add it…
ApoloApps Jul 14, 2026
d09a783
Web: Remove withoutReadObservation where not needed
ApoloApps Jul 15, 2026
7bf3b49
Web: Revert LocalActiveClipEventsTarget changes
ApoloApps Jul 15, 2026
abdb25a
Web: Fix WebDragAndDropManager logic by passing a Density provider (f…
ApoloApps Jul 15, 2026
108352e
Web: Defend WebWindowInsetsManager againts same density setter value
ApoloApps Jul 15, 2026
a8322fa
Remove todo from HapticFeedback
ApoloApps Jul 16, 2026
afc0aa4
Merge branch 'jb-main' into uiMediaSupport
ApoloApps Jul 28, 2026
936e9f5
Revert DesktopMediaEnvironment changes involving SystemTheme. Return …
ApoloApps Jul 28, 2026
f293351
Remove PlatformMediaEnvironment and its implementations and use UiMed…
ApoloApps Jul 28, 2026
36b842a
Rewrite MediaQueries with newly introduced MediaQueryListener in Web …
ApoloApps Jul 28, 2026
e7781af
Dump api files
ApoloApps Jul 29, 2026
0a4af71
Merge branch 'jb-main' into uiMediaSupport
ApoloApps Jul 29, 2026
303745e
Fix missing comma after iOS rebased changes
ApoloApps Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 29 additions & 81 deletions compose/ui/ui/api/ui.klib.api

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.skiko.hostOs

private var subscriberCount = 0
private var pollingJob: Job? = null
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* Copyright 2026 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

@file:OptIn(ExperimentalMediaQueryApi::class)

package androidx.compose.ui.platform

import androidx.compose.ui.ExperimentalMediaQueryApi
import androidx.compose.ui.UiMediaScope
import androidx.compose.ui.unit.Dp

internal class DesktopMediaEnvironment(val windowInfo: WindowInfo) : UiMediaScope {

override val windowPosture: UiMediaScope.Posture
get() = UiMediaScope.Posture.Flat

override val windowWidth: Dp
get() = windowInfo.containerDpSize.width

override val windowHeight: Dp
get() = windowInfo.containerDpSize.height

override val pointerPrecision: UiMediaScope.PointerPrecision
get() = UiMediaScope.PointerPrecision.Fine

override val keyboardKind: UiMediaScope.KeyboardKind
get() = UiMediaScope.KeyboardKind.Physical

override val hasMicrophone: Boolean
get() = true //inferred as always having at least 1 microphone

override val hasCamera: Boolean
get() = false //no reliable way to get it

override val viewingDistance: UiMediaScope.ViewingDistance
get() = UiMediaScope.ViewingDistance.Near

fun dispose() {
// No-op for now, but can be used to clean up resources if needed in the future.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ import androidx.compose.runtime.CompositionLocalContext
import androidx.compose.runtime.mutableStateSetOf
import androidx.compose.ui.ComposeFeatureFlags
import androidx.compose.ui.ComposeUiFlags
import androidx.compose.ui.ExperimentalMediaQueryApi
import androidx.compose.ui.ProvideSystemTheme
import androidx.compose.ui.UiMediaScope
import androidx.compose.ui.awt.AwtEventListener
import androidx.compose.ui.awt.AwtEventListeners
import androidx.compose.ui.awt.DebouncingEdtExecutor
Expand Down Expand Up @@ -51,6 +53,7 @@ import androidx.compose.ui.navigationevent.BackNavigationEventInput
import androidx.compose.ui.platform.AwtDragAndDropManager
import androidx.compose.ui.platform.DefaultInputModeManager
import androidx.compose.ui.platform.DelegateRootForTestListener
import androidx.compose.ui.platform.DesktopMediaEnvironment
import androidx.compose.ui.platform.DesktopTextInputService
import androidx.compose.ui.platform.DesktopTextInputService2
import androidx.compose.ui.platform.FrameRecomposer
Expand Down Expand Up @@ -164,6 +167,8 @@ internal class ComposeSceneMediator(
DesktopTextInputService2(platformComponent)
}

private val desktopMediaEnvironment = DesktopMediaEnvironment(windowInfo = windowContext.windowInfo)

// TODO: It must be shared between Compose instances.
// It's supposed to be stored in platform's root view or window.
val frameRecomposer = FrameRecomposer(coroutineContext, ::needRender)
Expand Down Expand Up @@ -628,6 +633,8 @@ internal class ComposeSceneMediator(
// Since rendering will not happen after, we need to execute all scheduled updates
interopContainer.dispose()

desktopMediaEnvironment.dispose()

_onComponentAttached = null
}

Expand Down Expand Up @@ -832,6 +839,8 @@ internal class ComposeSceneMediator(

private inner class DesktopPlatformContext : PlatformContext {
override val windowInfo: WindowInfo get() = windowContext.windowInfo
@OptIn(ExperimentalMediaQueryApi::class)
override val mediaEnvironment: UiMediaScope get() = desktopMediaEnvironment
override val architectureComponentsOwner get() = this@ComposeSceneMediator.architectureComponentsOwner
override val isWindowTransparent: Boolean get() = windowContext.isWindowTransparent

Expand Down

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please rename file to the MediaEnvironment.ios.kt‎ as well

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done (and fixed tests)

Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
/*
* Copyright 2026 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

@file:OptIn(ExperimentalMediaQueryApi::class)

package androidx.compose.ui.platform

import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.ExperimentalMediaQueryApi
import androidx.compose.ui.UiMediaScope
import androidx.compose.ui.uikit.InterfaceOrientation
import androidx.compose.ui.uikit.density
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import org.jetbrains.skiko.SystemTheme
import androidx.compose.ui.uikit.utils.CMPKeyValueObserver
import androidx.compose.ui.uikit.utils.CMPUIWindowSceneUtils
import androidx.compose.ui.window.KeyboardVisibilityListener
import androidx.compose.ui.window.KeyboardVisibilityObserver
import kotlinx.cinterop.BetaInteropApi
import kotlinx.cinterop.CPointed
import kotlinx.cinterop.CPointer
import kotlinx.cinterop.CValue
import kotlinx.cinterop.ObjCAction
import kotlinx.cinterop.useContents
import platform.AVFoundation.AVCaptureDevice
import platform.AVFoundation.AVCaptureDeviceWasConnectedNotification
import platform.AVFoundation.AVCaptureDeviceWasDisconnectedNotification
import platform.AVFoundation.AVMediaTypeAudio
import platform.AVFoundation.AVMediaTypeVideo
import platform.CoreGraphics.CGRect
import platform.Foundation.NSKeyValueObservingOptionNew
import platform.Foundation.NSNotification
import platform.Foundation.NSNotificationCenter
import platform.Foundation.NSSelectorFromString
import platform.Foundation.addObserver
import platform.Foundation.removeObserver
import platform.darwin.NSObject
import platform.UIKit.UIUserInterfaceStyle
import platform.UIKit.UIViewAnimationOptions
import platform.UIKit.UIWindow
import platform.UIKit.UIWindowScene

internal class MediaEnvironment(val windowInfo: WindowInfo) : UiMediaScope, KeyboardVisibilityObserver {

private var window: UIWindow? = null
/*
* Initial value is arbitrarily chosen to avoid propagating invalid value logic
* It's never the case in the real usage scenario to reflect that in type system
*/
internal val interfaceOrientationState: MutableState<InterfaceOrientation> = mutableStateOf(
InterfaceOrientation.Portrait
)
private val systemThemeState: MutableState<SystemTheme> = mutableStateOf(SystemTheme.UNKNOWN)

private val systemDensityState: MutableState<Density> = mutableStateOf(window?.density ?: Density(1f))

private val isImeShowing = mutableStateOf(KeyboardVisibilityListener.keyboardFrame.useContents { size.height > 0 })
private val pointerPrecisionState: MutableState<UiMediaScope.PointerPrecision> = mutableStateOf(
UiMediaScope.PointerPrecision.Coarse
)

private val hasMicrophoneState: MutableState<Boolean> = mutableStateOf(detectHasMicrophone())
private val hasCameraState: MutableState<Boolean> = mutableStateOf(detectHasCamera())

private fun detectHasMicrophone(): Boolean =
AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeAudio) != null

private fun detectHasCamera(): Boolean =
AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeVideo) != null


private fun updateCaptureDeviceAvailabilityState() {
hasMicrophoneState.value = detectHasMicrophone()
hasCameraState.value = detectHasCamera()
}

private val captureDeviceAvailabilityObserver = CaptureDeviceAvailabilityObserver(::updateCaptureDeviceAvailabilityState)

fun updateInterfaceOrientationState() {
currentInterfaceOrientation?.let {
interfaceOrientationState.value = it
}
}
fun updateUserInterfaceStyle(style: UIUserInterfaceStyle) {
systemThemeState.value = style.asComposeSystemTheme()
}
fun updatePointerPrecision(precision: UiMediaScope.PointerPrecision) {
pointerPrecisionState.value = precision
}

private val currentInterfaceOrientation: InterfaceOrientation?
get() {
return InterfaceOrientation.getByRawValue(
CMPUIWindowSceneUtils.interfaceOrientationForWindowScene(window?.windowScene)
)
}

private val interfaceOrientationObserver = SceneGeometryObserver {
updateInterfaceOrientationState()
}
fun onDidMoveToWindow(window: UIWindow?) {
this.window = window
interfaceOrientationObserver.windowScene = window?.windowScene
window ?: return

systemDensityState.value = window.density
updateInterfaceOrientationState()

}

val systemTheme: SystemTheme
get() = systemThemeState.value
val systemDensity: Density
get() = systemDensityState.value

fun startObserving() {
interfaceOrientationObserver.isObservingEnabled = true
captureDeviceAvailabilityObserver.isObservingEnabled = true
KeyboardVisibilityListener.addObserver(this)
}

fun stopObserving() {
interfaceOrientationObserver.isObservingEnabled = false
captureDeviceAvailabilityObserver.isObservingEnabled = false
KeyboardVisibilityListener.removeObserver(this)
}

override val windowPosture: UiMediaScope.Posture
get() = UiMediaScope.Posture.Flat //iOS doesn't have foldables yet!
override val windowWidth: Dp
get() = windowInfo.containerDpSize.width
override val windowHeight: Dp
get() = windowInfo.containerDpSize.height
override val pointerPrecision: UiMediaScope.PointerPrecision
get() = pointerPrecisionState.value
override val keyboardKind: UiMediaScope.KeyboardKind
get() = when {
isImeShowing.value -> UiMediaScope.KeyboardKind.Virtual
else -> UiMediaScope.KeyboardKind.None
}
override val hasMicrophone: Boolean
get() = hasMicrophoneState.value
override val hasCamera: Boolean
Comment thread
ASalavei marked this conversation as resolved.
get() = hasCameraState.value
override val viewingDistance: UiMediaScope.ViewingDistance
get() = UiMediaScope.ViewingDistance.Near

override fun keyboardWillShow(
targetFrame: CValue<CGRect>,
duration: Double,
animationOptions: UIViewAnimationOptions
) {
isImeShowing.value = targetFrame.useContents { size.height > 0 }
}

override fun keyboardWillHide(
targetFrame: CValue<CGRect>,
duration: Double,
animationOptions: UIViewAnimationOptions
) {
isImeShowing.value = false //targetFrame is CGRectZero.readValue()
}

override fun keyboardWillChangeFrame(
targetFrame: CValue<CGRect>,
duration: Double,
animationOptions: UIViewAnimationOptions
) {
isImeShowing.value = targetFrame.useContents { size.height > 0 }
}
}


private class SceneGeometryObserver(
val onGeometryChanged: () -> Unit
) : CMPKeyValueObserver() {
private val observingKey = "effectiveGeometry"

var windowScene: UIWindowScene? = null
set(value) {
if (field == value) return
removeObserverIfNeeded()
field = value
addObserverIfNeeded()
}

var isObservingEnabled = false
set(value) {
if (field == value) return
field = value
if (value) {
addObserverIfNeeded()
} else {
removeObserverIfNeeded()
}
}

private var isObservingAdded = false

private fun addObserverIfNeeded() {
if (isObservingEnabled && !isObservingAdded) {
isObservingAdded = true
windowScene?.addObserver(this, observingKey, NSKeyValueObservingOptionNew, null)
}
}

private fun removeObserverIfNeeded() {
windowScene?.removeObserver(this, observingKey)
isObservingAdded = false
}

override fun observeValueForKeyPath(
keyPath: String?,
ofObject: Any?,
change: Map<Any?, *>?,
context: CPointer<out CPointed>?
) {
onGeometryChanged()
}
}

private class CaptureDeviceAvailabilityObserver(
val onDeviceAvailabilityChanged: () -> Unit,
private val notificationCenter: NSNotificationCenter = NSNotificationCenter.defaultCenter
) : NSObject() {

var isObservingEnabled = false
set(value) {
if (field == value) return
field = value
if (value) {
addObservers()
} else {
removeObservers()
}
}

private fun addObservers() {
notificationCenter.addObserver(
observer = this,
selector = NSSelectorFromString(::deviceAvailabilityDidChange.name + ":"),
name = AVCaptureDeviceWasConnectedNotification,
`object` = null
)
notificationCenter.addObserver(
observer = this,
selector = NSSelectorFromString(::deviceAvailabilityDidChange.name + ":"),
name = AVCaptureDeviceWasDisconnectedNotification,
`object` = null
)
}

private fun removeObservers() {
notificationCenter.removeObserver(this)
}

@OptIn(BetaInteropApi::class)
@ObjCAction
fun deviceAvailabilityDidChange(arg: NSNotification) {
onDeviceAvailabilityChanged()
}
}

private fun UIUserInterfaceStyle.asComposeSystemTheme(): SystemTheme {
return when (this) {
UIUserInterfaceStyle.UIUserInterfaceStyleLight -> SystemTheme.LIGHT
UIUserInterfaceStyle.UIUserInterfaceStyleDark -> SystemTheme.DARK
else -> SystemTheme.UNKNOWN
}
}
Loading
Loading