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