1 /*
2 * Copyright 2024 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package androidx.compose.ui.platform
18
19 import android.annotation.SuppressLint
20 import android.app.Activity
21 import android.content.Context
22 import android.content.ContextWrapper
23 import android.content.res.Configuration
24 import android.graphics.Point
25 import android.graphics.Rect
26 import android.os.Build
27 import android.view.Display
28 import android.view.DisplayCutout
29 import android.view.WindowManager
30 import androidx.annotation.RequiresApi
31 import androidx.compose.runtime.MutableState
32 import androidx.compose.runtime.getValue
33 import androidx.compose.runtime.mutableStateOf
34 import androidx.compose.runtime.setValue
35 import androidx.compose.ui.input.pointer.PointerKeyboardModifiers
36 import androidx.compose.ui.platform.WindowInfoImpl.Companion.GlobalKeyboardModifiers
37 import androidx.compose.ui.unit.IntSize
38 import androidx.compose.ui.util.fastRoundToInt
39 import java.lang.reflect.InvocationTargetException
40
41 /**
42 * WindowInfo that only calculates [containerSize] if the property has been read, to avoid expensive
43 * size calculation when no one is reading the value.
44 */
45 internal class LazyWindowInfo : WindowInfo {
46 private var onInitializeContainerSize: (() -> IntSize)? = null
47 private var _containerSize: MutableState<IntSize>? = null
48
49 override var isWindowFocused: Boolean by mutableStateOf(false)
50
51 override var keyboardModifiers: PointerKeyboardModifiers
52 get() = GlobalKeyboardModifiers.value
53 set(value) {
54 GlobalKeyboardModifiers.value = value
55 }
56
updateContainerSizeIfObservednull57 inline fun updateContainerSizeIfObserved(calculateContainerSize: () -> IntSize) {
58 _containerSize?.let { it.value = calculateContainerSize() }
59 }
60
setOnInitializeContainerSizenull61 fun setOnInitializeContainerSize(onInitializeContainerSize: (() -> IntSize)?) {
62 // If we have already initialized, no need to set a listener here
63 if (_containerSize == null) {
64 this.onInitializeContainerSize = onInitializeContainerSize
65 }
66 }
67
68 override val containerSize: IntSize
69 get() {
70 if (_containerSize == null) {
71 val initialSize = onInitializeContainerSize?.invoke() ?: IntSize.Zero
72 _containerSize = mutableStateOf(initialSize)
73 onInitializeContainerSize = null
74 }
75 return _containerSize!!.value
76 }
77 }
78
79 /**
80 * TODO: b/369334429 Temporary fork of WindowMetricsCalculator logic until b/360934048 and
81 * b/369170239 are resolved
82 */
calculateWindowSizenull83 internal fun calculateWindowSize(androidComposeView: AndroidComposeView): IntSize {
84 val context = androidComposeView.context
85 val activity = context.findActivity()
86 if (activity != null) {
87 val bounds = BoundsHelper.getInstance().currentWindowBounds(activity)
88 return IntSize(width = bounds.width(), height = bounds.height())
89 } else {
90 // Fallback behavior for views created with an applicationContext / other non-Activity host
91 val configuration = context.resources.configuration
92 val density = context.resources.displayMetrics.density
93 val width = (configuration.screenWidthDp * density).fastRoundToInt()
94 val height = (configuration.screenHeightDp * density).fastRoundToInt()
95 return IntSize(width = width, height = height)
96 }
97 }
98
findActivitynull99 private tailrec fun Context.findActivity(): Activity? =
100 when (this) {
101 is Activity -> this
102 is ContextWrapper -> this.baseContext.findActivity()
103 else -> null
104 }
105
106 private interface BoundsHelper {
107 /** Compute the current bounds for the given [Activity]. */
currentWindowBoundsnull108 fun currentWindowBounds(activity: Activity): Rect
109
110 companion object {
111 fun getInstance(): BoundsHelper {
112 return when {
113 Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
114 BoundsHelperApi30Impl
115 }
116 Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> {
117 BoundsHelperApi29Impl
118 }
119 Build.VERSION.SDK_INT >= Build.VERSION_CODES.P -> {
120 BoundsHelperApi28Impl
121 }
122 Build.VERSION.SDK_INT >= Build.VERSION_CODES.N -> {
123 BoundsHelperApi24Impl
124 }
125 else -> {
126 BoundsHelperApi16Impl
127 }
128 }
129 }
130 }
131 }
132
133 @RequiresApi(Build.VERSION_CODES.R)
134 private object BoundsHelperApi30Impl : BoundsHelper {
currentWindowBoundsnull135 override fun currentWindowBounds(activity: Activity): Rect {
136 val wm = activity.getSystemService(WindowManager::class.java)
137 return wm.currentWindowMetrics.bounds
138 }
139 }
140
141 @RequiresApi(Build.VERSION_CODES.Q)
142 private object BoundsHelperApi29Impl : BoundsHelper {
143
144 /** Computes the window bounds for [Build.VERSION_CODES.Q]. */
145 @SuppressLint("BanUncheckedReflection", "BlockedPrivateApi")
currentWindowBoundsnull146 override fun currentWindowBounds(activity: Activity): Rect {
147 var bounds: Rect
148 val config = activity.resources.configuration
149 try {
150 val windowConfigField =
151 Configuration::class.java.getDeclaredField("windowConfiguration")
152 windowConfigField.isAccessible = true
153 val windowConfig = windowConfigField[config]
154 val getBoundsMethod = windowConfig.javaClass.getDeclaredMethod("getBounds")
155 bounds = Rect(getBoundsMethod.invoke(windowConfig) as Rect)
156 } catch (e: Exception) {
157 when (e) {
158 is NoSuchFieldException,
159 is NoSuchMethodException,
160 is IllegalAccessException,
161 is InvocationTargetException -> {
162 // If reflection fails for some reason default to the P implementation which
163 // still has the ability to account for display cutouts.
164 bounds = BoundsHelperApi28Impl.currentWindowBounds(activity)
165 }
166 else -> throw e
167 }
168 }
169 return bounds
170 }
171 }
172
173 @RequiresApi(Build.VERSION_CODES.P)
174 private object BoundsHelperApi28Impl : BoundsHelper {
175
176 /**
177 * Computes the window bounds for [Build.VERSION_CODES.P].
178 *
179 * NOTE: This method may result in incorrect values if the [android.content.res.Resources] value
180 * stored at 'navigation_bar_height' does not match the true navigation bar inset on the window.
181 */
182 @SuppressLint("BanUncheckedReflection", "BlockedPrivateApi")
currentWindowBoundsnull183 override fun currentWindowBounds(activity: Activity): Rect {
184 val bounds = Rect()
185 val config = activity.resources.configuration
186 try {
187 val windowConfigField =
188 Configuration::class.java.getDeclaredField("windowConfiguration")
189 windowConfigField.isAccessible = true
190 val windowConfig = windowConfigField[config]
191
192 // In multi-window mode we'll use the WindowConfiguration#mBounds property which
193 // should match the window size. Otherwise we'll use the mAppBounds property and
194 // will adjust it below.
195 if (activity.isInMultiWindowMode) {
196 val getAppBounds = windowConfig.javaClass.getDeclaredMethod("getBounds")
197 bounds.set((getAppBounds.invoke(windowConfig) as Rect))
198 } else {
199 val getAppBounds = windowConfig.javaClass.getDeclaredMethod("getAppBounds")
200 bounds.set((getAppBounds.invoke(windowConfig) as Rect))
201 }
202 } catch (e: Exception) {
203 when (e) {
204 is NoSuchFieldException,
205 is NoSuchMethodException,
206 is IllegalAccessException,
207 is InvocationTargetException -> {
208 getRectSizeFromDisplay(activity, bounds)
209 }
210 else -> throw e
211 }
212 }
213
214 val platformWindowManager = activity.windowManager
215
216 // [WindowManager#getDefaultDisplay] is deprecated but we have this for
217 // compatibility with older versions
218 @Suppress("DEPRECATION") val currentDisplay = platformWindowManager.defaultDisplay
219 val realDisplaySize = Point()
220 @Suppress("DEPRECATION") currentDisplay.getRealSize(realDisplaySize)
221
222 if (!activity.isInMultiWindowMode) {
223 // The activity is not in multi-window mode. Check if the addition of the
224 // navigation bar size to mAppBounds results in the real display size and if so
225 // assume the nav bar height should be added to the result.
226 val navigationBarHeight = getNavigationBarHeight(activity)
227 if (bounds.bottom + navigationBarHeight == realDisplaySize.y) {
228 bounds.bottom += navigationBarHeight
229 } else if (bounds.right + navigationBarHeight == realDisplaySize.x) {
230 bounds.right += navigationBarHeight
231 } else if (bounds.left == navigationBarHeight) {
232 bounds.left = 0
233 }
234 }
235 if (
236 (bounds.width() < realDisplaySize.x || bounds.height() < realDisplaySize.y) &&
237 !activity.isInMultiWindowMode
238 ) {
239 // If the corrected bounds are not the same as the display size and the activity is
240 // not in multi-window mode it is possible there are unreported cutouts inset-ing
241 // the window depending on the layoutInCutoutMode. Check for them here by getting
242 // the cutout from the display itself.
243 val displayCutout = getCutoutForDisplay(currentDisplay)
244 if (displayCutout != null) {
245 if (bounds.left == displayCutout.safeInsetLeft) {
246 bounds.left = 0
247 }
248 if (realDisplaySize.x - bounds.right == displayCutout.safeInsetRight) {
249 bounds.right += displayCutout.safeInsetRight
250 }
251 if (bounds.top == displayCutout.safeInsetTop) {
252 bounds.top = 0
253 }
254 if (realDisplaySize.y - bounds.bottom == displayCutout.safeInsetBottom) {
255 bounds.bottom += displayCutout.safeInsetBottom
256 }
257 }
258 }
259 return bounds
260 }
261 }
262
263 @RequiresApi(Build.VERSION_CODES.N)
264 private object BoundsHelperApi24Impl : BoundsHelper {
265
266 /**
267 * Computes the window bounds for platforms between [Build.VERSION_CODES.N] and
268 * [Build.VERSION_CODES.O_MR1], inclusive.
269 *
270 * NOTE: This method may result in incorrect values under the following conditions:
271 * * If the activity is in multi-window mode the origin of the returned bounds will always be
272 * anchored at (0, 0).
273 * * If the [android.content.res.Resources] value stored at 'navigation_bar_height' does not
274 * match the true navigation bar size the returned bounds will not take into account the
275 * navigation bar.
276 */
currentWindowBoundsnull277 override fun currentWindowBounds(activity: Activity): Rect {
278 val bounds = Rect()
279 // [WindowManager#getDefaultDisplay] is deprecated but we have this for
280 // compatibility with older versions
281 @Suppress("DEPRECATION") val defaultDisplay = activity.windowManager.defaultDisplay
282 // [Display#getRectSize] is deprecated but we have this for
283 // compatibility with older versions
284 @Suppress("DEPRECATION") defaultDisplay.getRectSize(bounds)
285 if (!activity.isInMultiWindowMode) {
286 // The activity is not in multi-window mode. Check if the addition of the
287 // navigation bar size to Display#getSize() results in the real display size and
288 // if so return this value. If not, return the result of Display#getSize().
289 val realDisplaySize = Point()
290 @Suppress("DEPRECATION") defaultDisplay.getRealSize(realDisplaySize)
291 val navigationBarHeight = getNavigationBarHeight(activity)
292 if (bounds.bottom + navigationBarHeight == realDisplaySize.y) {
293 bounds.bottom += navigationBarHeight
294 } else if (bounds.right + navigationBarHeight == realDisplaySize.x) {
295 bounds.right += navigationBarHeight
296 }
297 }
298 return bounds
299 }
300 }
301
302 private object BoundsHelperApi16Impl : BoundsHelper {
303
304 /**
305 * Computes the window bounds for platforms between [Build.VERSION_CODES.JELLY_BEAN] and
306 * [Build.VERSION_CODES.M], inclusive.
307 *
308 * Given that multi-window mode isn't supported before N we simply return the real display size
309 * which should match the window size of a full-screen app.
310 */
currentWindowBoundsnull311 override fun currentWindowBounds(activity: Activity): Rect {
312 // [WindowManager#getDefaultDisplay] is deprecated but we have this for
313 // compatibility with older versions
314 @Suppress("DEPRECATION") val defaultDisplay = activity.windowManager.defaultDisplay
315 val realDisplaySize = Point()
316 @Suppress("DEPRECATION") defaultDisplay.getRealSize(realDisplaySize)
317 val bounds = Rect()
318 if (realDisplaySize.x == 0 || realDisplaySize.y == 0) {
319 // [Display#getRectSize] is deprecated but we have this for
320 // compatibility with older versions
321 @Suppress("DEPRECATION") defaultDisplay.getRectSize(bounds)
322 } else {
323 bounds.right = realDisplaySize.x
324 bounds.bottom = realDisplaySize.y
325 }
326 return bounds
327 }
328 }
329
330 /**
331 * Returns the [android.content.res.Resources] value stored as 'navigation_bar_height'.
332 *
333 * Note: This is error-prone and is **not** the recommended way to determine the size of the
334 * overlapping region between the navigation bar and a given window. The best approach is to acquire
335 * the [android.view.WindowInsets].
336 */
getNavigationBarHeightnull337 private fun getNavigationBarHeight(context: Context): Int {
338 val resources = context.resources
339 val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android")
340 return if (resourceId > 0) {
341 resources.getDimensionPixelSize(resourceId)
342 } else 0
343 }
344
getRectSizeFromDisplaynull345 private fun getRectSizeFromDisplay(activity: Activity, bounds: Rect) {
346 // [WindowManager#getDefaultDisplay] is deprecated but we have this for
347 // compatibility with older versions
348 @Suppress("DEPRECATION") val defaultDisplay = activity.windowManager.defaultDisplay
349 // [Display#getRectSize] is deprecated but we have this for
350 // compatibility with older versions
351 @Suppress("DEPRECATION") defaultDisplay.getRectSize(bounds)
352 }
353
354 /**
355 * Returns the [DisplayCutout] for the given display. Note that display cutout returned here is for
356 * the display and the insets provided are in the display coordinate system.
357 *
358 * @return the display cutout for the given display.
359 */
360 @SuppressLint("BanUncheckedReflection")
361 @RequiresApi(Build.VERSION_CODES.P)
getCutoutForDisplaynull362 private fun getCutoutForDisplay(display: Display): DisplayCutout? {
363 var displayCutout: DisplayCutout? = null
364 try {
365 val displayInfoClass = Class.forName("android.view.DisplayInfo")
366 val displayInfoConstructor = displayInfoClass.getConstructor()
367 displayInfoConstructor.isAccessible = true
368 val displayInfo = displayInfoConstructor.newInstance()
369 val getDisplayInfoMethod =
370 display.javaClass.getDeclaredMethod("getDisplayInfo", displayInfo.javaClass)
371 getDisplayInfoMethod.isAccessible = true
372 getDisplayInfoMethod.invoke(display, displayInfo)
373 val displayCutoutField = displayInfo.javaClass.getDeclaredField("displayCutout")
374 displayCutoutField.isAccessible = true
375 val cutout = displayCutoutField[displayInfo]
376 if (cutout is DisplayCutout) {
377 displayCutout = cutout
378 }
379 } catch (e: Exception) {
380 when (e) {
381 is ClassNotFoundException,
382 is NoSuchMethodException,
383 is NoSuchFieldException,
384 is IllegalAccessException,
385 is InvocationTargetException,
386 is InstantiationException -> {}
387 else -> throw e
388 }
389 }
390 return displayCutout
391 }
392