From 7bd23b4757245e67b2488d1dd6b381acf9dd3448 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8D=E8=A6=81=E7=B3=96=E9=86=8B=E6=94=BE=E6=A4=92?= =?UTF-8?q?=E7=9B=90?= <62319450+Moriafly@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:33:11 +0800 Subject: [PATCH 1/2] fix(adaptive-window): backport deferred window metrics updates Backport AndroidX 48413c3's pending-update flag and post-global-layout refresh for API 32 and below, covering SaltUI's supported API 23-32 range. Use the same WindowMetricsCalculator and supported-context unwrapping as AndroidWindowInfo so older Android versions retain window and split-screen semantics. Declare the existing Compose runtime dependency explicitly for compilation, keeping its current 1.5.0 baseline. Keep state on the host View, detach and restore listeners with its lifecycle, and retain upstream source references and Apache attribution. Android 33+ and other platforms keep their existing window information path. Require an actual hinge sensor to avoid misclassifying regular emulators as foldables. Enable Android resources for a collision-free View tag ID. Validation: module publication and SPA build passed; all 438 existing SPA unit tests passed. Android 12 runtime checks covered repeated rotation in the main and player screens. Earlier checks also covered window resizing, tablet layout, Activity recreation and locale retention. API 23-29 have not been runtime-verified; Android 16 emulator disconnects prevented validation. --- gradle/libs.versions.toml | 2 + salt-adaptive-window/build.gradle.kts | 5 + .../window/AndroidWindowMetricsState.kt | 161 ++++++++++++++++++ .../window/SaltAdaptiveWindow.android.kt | 30 +++- .../src/androidMain/res/values/ids.xml | 4 + 5 files changed, 195 insertions(+), 7 deletions(-) create mode 100644 salt-adaptive-window/src/androidMain/kotlin/com/moriafly/salt/adaptive/window/AndroidWindowMetricsState.kt create mode 100644 salt-adaptive-window/src/androidMain/res/values/ids.xml 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..738108f2 --- /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 @@ + + + + From e98502a7bc7cfdbf9cf9f085b773361c1b5a4227 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8D=E8=A6=81=E7=B3=96=E9=86=8B=E6=94=BE=E6=A4=92?= =?UTF-8?q?=E7=9B=90?= <62319450+Moriafly@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:45:13 +0800 Subject: [PATCH 2/2] fix(adaptive-window): remove unnecessary cast parentheses Remove redundant parentheses around the safe cast in the View-tag state lookup to satisfy CodeFactor detekt.UnnecessaryParentheses. The safe cast still binds before the Elvis fallback, preserving state reuse and initialization behavior. Validation: :salt-adaptive-window:compileAndroidMain passed offline; git diff --check passed. This syntax-only change does not require additional tests. --- .../moriafly/salt/adaptive/window/AndroidWindowMetricsState.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 738108f2..f1b93dc1 100644 --- 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 @@ -39,7 +39,7 @@ import androidx.window.layout.WindowMetricsCalculator internal fun View.currentWindowMetricsSize(configuration: Configuration): DpSize { val state = - (getTag(R.id.salt_adaptive_window_metrics_state) as? AndroidWindowMetricsState) + getTag(R.id.salt_adaptive_window_metrics_state) as? AndroidWindowMetricsState ?: AndroidWindowMetricsState(this).also { setTag(R.id.salt_adaptive_window_metrics_state, it) }