Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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" }
Expand Down
5 changes: 5 additions & 0 deletions salt-adaptive-window/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ kotlin {
compileSdk = 37
minSdk = 23

androidResources {
enable = true
}

compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
Expand Down Expand Up @@ -127,6 +131,7 @@ kotlin {

androidMain.dependencies {
implementation(libs.compose.ui)
implementation(libs.window)
}

val desktopMain by getting
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,16 @@ 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
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

Expand All @@ -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
)
}

Expand Down Expand Up @@ -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
4 changes: 4 additions & 0 deletions salt-adaptive-window/src/androidMain/res/values/ids.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<item name="salt_adaptive_window_metrics_state" type="id" />
</resources>
Loading