diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4d4dba1d..65ae7153 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,7 @@ material = "1.13.0" material3 = "1.4.0" compose-multiplatform = "1.12.2" activity-compose = "1.12.2" +window = "1.5.0" hiddenapibypass = "6.1" jna = "5.18.1" maven-publish = "0.36.0" @@ -33,6 +34,7 @@ compose-components-resources = { group = "org.jetbrains.compose.components", nam compose-ui-test = { group = "org.jetbrains.compose.ui", name = "ui-test", version.ref = "compose-multiplatform" } compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } +window = { group = "androidx.window", name = "window", version.ref = "window" } hiddenapibypass = { group = "org.lsposed.hiddenapibypass", name = "hiddenapibypass", version.ref = "hiddenapibypass" } jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" } jna-platform = { group = "net.java.dev.jna", name = "jna-platform", version.ref = "jna" } diff --git a/salt-adaptive-window/build.gradle.kts b/salt-adaptive-window/build.gradle.kts index f9e6ccad..f34b9875 100644 --- a/salt-adaptive-window/build.gradle.kts +++ b/salt-adaptive-window/build.gradle.kts @@ -89,6 +89,10 @@ kotlin { compileSdk = 37 minSdk = 23 + androidResources { + enable = true + } + compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } @@ -127,6 +131,7 @@ kotlin { androidMain.dependencies { implementation(libs.compose.ui) + implementation(libs.window) } val desktopMain by getting diff --git a/salt-adaptive-window/src/androidMain/kotlin/com/moriafly/salt/adaptive/window/AndroidWindowMetricsState.kt b/salt-adaptive-window/src/androidMain/kotlin/com/moriafly/salt/adaptive/window/AndroidWindowMetricsState.kt new file mode 100644 index 00000000..f1b93dc1 --- /dev/null +++ b/salt-adaptive-window/src/androidMain/kotlin/com/moriafly/salt/adaptive/window/AndroidWindowMetricsState.kt @@ -0,0 +1,161 @@ +/* + * Copyright 2024-2025 The Android Open Source Project + * Copyright (C) 2026 Moriafly + * + * 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. + */ + +package com.moriafly.salt.adaptive.window + +import android.app.Activity +import android.app.Application +import android.content.ComponentCallbacks +import android.content.Context +import android.content.ContextWrapper +import android.content.pm.ActivityInfo +import android.content.res.Configuration +import android.inputmethodservice.InputMethodService +import android.view.View +import android.view.ViewTreeObserver +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toSize +import androidx.window.layout.WindowMetricsCalculator + +internal fun View.currentWindowMetricsSize(configuration: Configuration): DpSize { + val state = + getTag(R.id.salt_adaptive_window_metrics_state) as? AndroidWindowMetricsState + ?: AndroidWindowMetricsState(this).also { + setTag(R.id.salt_adaptive_window_metrics_state, it) + } + state.onConfigurationChanged(configuration) + return state.size +} + +// Backport of the pending-update/global-layout mechanism from AndroidX ComposeViewContext: +// https://android.googlesource.com/platform/frameworks/support/+/48413c3f127790e362bd05cc72dbc1ac22628a3b +// The View tag replaces ComposeViewContext ownership; listeners are removed on detach and restored +// on reattach. No global references retain the View or its Activity. +private class AndroidWindowMetricsState(private val view: View) : + ComponentCallbacks, + View.OnAttachStateChangeListener, + ViewTreeObserver.OnGlobalLayoutListener { + private val currentConfiguration = Configuration(view.resources.configuration) + private var observer: ViewTreeObserver? = null + private var pendingWindowInfoUpdate = false + + var size by mutableStateOf(readSize()) + private set + + init { + view.addOnAttachStateChangeListener(this) + if (view.isAttachedToWindow) observeLayout() + } + + override fun onViewAttachedToWindow(view: View) { + observeLayout() + } + + override fun onViewDetachedFromWindow(view: View) { + stopObservingLayout() + } + + override fun onGlobalLayout() { + if (pendingWindowInfoUpdate) { + pendingWindowInfoUpdate = false + size = readSize() + } + } + + override fun onConfigurationChanged(configuration: Configuration) { + val changedFlags = currentConfiguration.updateFrom(configuration) + if (changedFlags and MaskForNonWindowMetricsChanges.inv() != 0) { + // Do not query bounds here: API <= 32 can still report the old window frame. + pendingWindowInfoUpdate = true + } + } + + @Deprecated("This callback is superseded by onTrimMemory") + override fun onLowMemory() { + // This observer owns no resource caches. + } + + private fun observeLayout() { + stopObservingLayout() + view.context.registerComponentCallbacks(this) + observer = view.viewTreeObserver.also { it.addOnGlobalLayoutListener(this) } + size = readSize() + } + + private fun stopObservingLayout() { + observer?.let { + if (it.isAlive) it.removeOnGlobalLayoutListener(this) + view.context.unregisterComponentCallbacks(this) + } + observer = null + pendingWindowInfoUpdate = false + } + + // Match AndroidWindowInfo.calculateWindowSize, including pre-30 window metrics and the + // unsupported-context fallback. Display metrics alone would break split-screen windows. + private fun readSize(): DpSize { + val context = view.context + val unwrapped = tryUnwrapContext(context) + return if (unwrapped != null) { + val metrics = WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(unwrapped) + with(Density(unwrapped)) { + IntSize(metrics.bounds.width(), metrics.bounds.height()).toSize().toDpSize() + } + } else { + val configuration = context.resources.configuration + DpSize(configuration.screenWidthDp.dp, configuration.screenHeightDp.dp) + } + } +} + +// Copied from AndroidWindowInfo: unwrap supported contexts before calling WindowMetricsCalculator +// to retain its workarounds for b/449386176 and b/449389108. +private fun tryUnwrapContext(context: Context): Context? { + var iterator = context + while (iterator is ContextWrapper) { + if (iterator is Activity || iterator is InputMethodService || iterator is Application) { + return iterator + } else if (iterator.baseContext == null) { + return null + } + iterator = iterator.baseContext + } + return null +} + +// Copied from ComposeViewContext: changes excluded here cannot affect window metrics. +private const val MaskForNonWindowMetricsChanges = + ActivityInfo.CONFIG_MCC or + ActivityInfo.CONFIG_MNC or + ActivityInfo.CONFIG_LOCALE or + ActivityInfo.CONFIG_TOUCHSCREEN or + ActivityInfo.CONFIG_KEYBOARD or + ActivityInfo.CONFIG_KEYBOARD_HIDDEN or + ActivityInfo.CONFIG_NAVIGATION or + ActivityInfo.CONFIG_UI_MODE or + ActivityInfo.CONFIG_LAYOUT_DIRECTION or + ActivityInfo.CONFIG_FONT_SCALE or + ActivityInfo.CONFIG_COLOR_MODE or + ActivityInfo.CONFIG_GRAMMATICAL_GENDER or + ActivityInfo.CONFIG_FONT_WEIGHT_ADJUSTMENT or + ActivityInfo.CONFIG_ASSETS_PATHS diff --git a/salt-adaptive-window/src/androidMain/kotlin/com/moriafly/salt/adaptive/window/SaltAdaptiveWindow.android.kt b/salt-adaptive-window/src/androidMain/kotlin/com/moriafly/salt/adaptive/window/SaltAdaptiveWindow.android.kt index 6466f24c..61fc8a42 100644 --- a/salt-adaptive-window/src/androidMain/kotlin/com/moriafly/salt/adaptive/window/SaltAdaptiveWindow.android.kt +++ b/salt-adaptive-window/src/androidMain/kotlin/com/moriafly/salt/adaptive/window/SaltAdaptiveWindow.android.kt @@ -21,6 +21,8 @@ package com.moriafly.salt.adaptive.window import android.content.Context import android.content.pm.PackageManager +import android.hardware.Sensor +import android.hardware.SensorManager import android.os.Build import android.util.DisplayMetrics import android.view.WindowManager @@ -28,6 +30,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalAccessorScope import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalWindowInfo import kotlin.math.min @@ -37,11 +40,21 @@ actual fun currentSaltAdaptiveWindow(): SaltAdaptiveWindow = currentPlatformSalt internal actual fun CompositionLocalAccessorScope.platformSaltAdaptiveWindow(): SaltAdaptiveWindow { val context = LocalContext.currentValue val configuration = LocalConfiguration.currentValue + val density = configuration.densityDpi / DisplayMetrics.DENSITY_DEFAULT.toFloat() + // On API <= 32, WindowInfo can cache stale bounds queried during configuration changes. + // AndroidX fixes b/525259151, b/525284940 and b/508799456 by reading again after global layout: + // https://android.googlesource.com/platform/frameworks/support/+/48413c3f127790e362bd05cc72dbc1ac22628a3b + // The fix is present in Compose UI 1.13.0-alpha01, but absent from 1.12.1. + // Apply the same API < 33 condition as AndroidX; WindowMetricsCalculator supports older APIs. + val windowSize = + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + LocalView.currentValue.currentWindowMetricsSize(configuration) + } else { + LocalWindowInfo.currentValue.containerDpSize + } return saltAdaptiveWindow( - formFactor = context.deviceFormFactor( - density = configuration.densityDpi / DisplayMetrics.DENSITY_DEFAULT.toFloat() - ), - windowSize = LocalWindowInfo.currentValue.containerDpSize + formFactor = context.deviceFormFactor(density), + windowSize = windowSize ) } @@ -71,8 +84,11 @@ private fun Context.isAutomotive(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && packageManager.hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE) -// A hinge angle sensor is the only foldable signal the platform exposes without the Jetpack -// WindowManager dependency, so this is a heuristic rather than a guarantee. +// A hinge angle sensor is the platform signal used here for foldable detection. +// This is a heuristic rather than a guarantee. +// Some emulator images advertise the feature even when no hinge sensor is available. private fun Context.isFoldable(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && - packageManager.hasSystemFeature(PackageManager.FEATURE_SENSOR_HINGE_ANGLE) + packageManager.hasSystemFeature(PackageManager.FEATURE_SENSOR_HINGE_ANGLE) && + (getSystemService(Context.SENSOR_SERVICE) as? SensorManager) + ?.getDefaultSensor(Sensor.TYPE_HINGE_ANGLE) != null diff --git a/salt-adaptive-window/src/androidMain/res/values/ids.xml b/salt-adaptive-window/src/androidMain/res/values/ids.xml new file mode 100644 index 00000000..7a1ce8d1 --- /dev/null +++ b/salt-adaptive-window/src/androidMain/res/values/ids.xml @@ -0,0 +1,4 @@ + + + +