1 // Copyright 2015-present 650 Industries. All rights reserved.
2 package host.exp.exponent.utils
3 
4 import expo.modules.manifests.core.Manifest
5 import android.app.Activity
6 import android.content.pm.ActivityInfo
7 import android.view.WindowManager
8 import host.exp.exponent.ExponentManifest
9 import android.view.WindowInsets
10 import host.exp.exponent.ExponentManifest.BitmapListener
11 import android.graphics.Bitmap
12 import android.app.ActivityManager.TaskDescription
13 import android.graphics.Color
14 import android.os.Build
15 import android.view.View
16 import androidx.annotation.UiThread
17 import androidx.appcompat.app.AppCompatActivity
18 import androidx.appcompat.app.AppCompatDelegate
19 import androidx.core.view.ViewCompat
20 import host.exp.exponent.analytics.EXL
21 
22 object ExperienceActivityUtils {
23   private val TAG = ExperienceActivityUtils::class.java.simpleName
24 
25   private const val STATUS_BAR_STYLE_DARK_CONTENT = "dark-content"
26   private const val STATUS_BAR_STYLE_LIGHT_CONTENT = "light-content"
27 
28   fun updateOrientation(manifest: Manifest, activity: Activity) {
29     val orientation = manifest.getOrientation()
30     if (orientation == null) {
31       activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
32       return
33     }
34     when (orientation) {
35       "default" -> activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
36       "portrait" -> activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
37       "landscape" -> activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
38     }
39   }
40 
41   fun updateSoftwareKeyboardLayoutMode(manifest: Manifest, activity: Activity) {
42     val keyboardLayoutMode = manifest.getAndroidKeyboardLayoutMode() ?: "resize"
43 
44     // It's only necessary to set this manually for pan, resize is the default for the activity.
45     if (keyboardLayoutMode == "pan") {
46       activity.window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN)
47     }
48   }
49 
50   // region user interface style - light/dark/automatic mode
51   /**
52    * Sets uiMode to according to what is being set in manifest.
53    */
54   fun overrideUiMode(manifest: Manifest, activity: AppCompatActivity) {
55     val userInterfaceStyle = manifest.getAndroidUserInterfaceStyle() ?: "light"
56     activity.delegate.localNightMode = nightModeFromString(userInterfaceStyle)
57   }
58 
59   private fun nightModeFromString(userInterfaceStyle: String?): Int {
60     return if (userInterfaceStyle == null) {
61       AppCompatDelegate.MODE_NIGHT_NO
62     } else when (userInterfaceStyle) {
63       "automatic" -> {
64         if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
65           AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY
66         } else AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
67       }
68       "dark" -> AppCompatDelegate.MODE_NIGHT_YES
69       "light" -> AppCompatDelegate.MODE_NIGHT_NO
70       else -> AppCompatDelegate.MODE_NIGHT_NO
71     }
72   }
73 
74   // endregion
75 
76   // region StatusBar configuration
77 
78   /**
79    * React Native is not using flag [WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS] nor view/manifest attribute 'android:windowTranslucentStatus'
80    * (https://developer.android.com/reference/android/view/WindowManager.LayoutParams.html#FLAG_TRANSLUCENT_STATUS)
81    * (https://developer.android.com/reference/android/R.attr.html#windowTranslucentStatus)
82    * Instead it's using [WindowInsets] to limit available space on the screen ([com.facebook.react.modules.statusbar.StatusBarModule.setTranslucent]).
83    *
84    *
85    * In case 'android:'windowTranslucentStatus' is used in activity's theme, it has to be removed in order to make RN's Status Bar API work.
86    * Out approach to achieve translucency of StatusBar has to be aligned with RN's approach to ensure [com.facebook.react.modules.statusbar.StatusBarModule] works.
87    *
88    *
89    * Links to follow in case of need of more detailed understating.
90    * https://chris.banes.dev/talks/2017/becoming-a-master-window-fitter-lon/
91    * https://www.youtube.com/watch?v=_mGDMVRO3iE
92    */
93   fun configureStatusBar(manifest: Manifest, activity: Activity) {
94     val statusBarOptions = manifest.getAndroidStatusBarOptions()
95     val statusBarStyle = statusBarOptions?.optString(ExponentManifest.MANIFEST_STATUS_BAR_APPEARANCE)
96     val statusBarBackgroundColor = statusBarOptions?.optString(ExponentManifest.MANIFEST_STATUS_BAR_BACKGROUND_COLOR, null)
97 
98     val statusBarHidden = statusBarOptions != null && statusBarOptions.optBoolean(
99       ExponentManifest.MANIFEST_STATUS_BAR_HIDDEN,
100       false
101     )
102     val statusBarTranslucent = statusBarOptions == null || statusBarOptions.optBoolean(
103       ExponentManifest.MANIFEST_STATUS_BAR_TRANSLUCENT,
104       true
105     )
106 
107     activity.runOnUiThread {
108       // clear android:windowTranslucentStatus flag from Window as RN achieves translucency using WindowInsets
109       activity.window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
110 
111       setHidden(statusBarHidden, activity)
112 
113       setTranslucent(statusBarTranslucent, activity)
114 
115       val appliedStatusBarStyle = setStyle(statusBarStyle, activity)
116 
117       // Color passed from manifest is in format '#RRGGBB(AA)' and Android uses '#AARRGGBB'
118       val normalizedStatusBarBackgroundColor = RGBAtoARGB(statusBarBackgroundColor)
119 
120       if (normalizedStatusBarBackgroundColor == null || !ColorParser.isValid(normalizedStatusBarBackgroundColor)) {
121         // backgroundColor is invalid or not set
122         if (appliedStatusBarStyle == STATUS_BAR_STYLE_LIGHT_CONTENT) {
123           // appliedStatusBarStyle is "light-content" so background color should be semi transparent black
124           setColor(Color.parseColor("#88000000"), activity)
125         } else {
126           // otherwise it has to be transparent
127           setColor(Color.TRANSPARENT, activity)
128         }
129       } else {
130         setColor(Color.parseColor(normalizedStatusBarBackgroundColor), activity)
131       }
132     }
133   }
134 
135   /**
136    * If the string conforms to the "#RRGGBBAA" format then it's converted into the "#AARRGGBB" format.
137    * Otherwise noop.
138    */
139   private fun RGBAtoARGB(rgba: String?): String? {
140     if (rgba == null) {
141       return null
142     }
143     return if (rgba.startsWith("#") && rgba.length == 9) {
144       "#" + rgba.substring(7, 9) + rgba.substring(1, 7)
145     } else rgba
146   }
147 
148   @UiThread
149   fun setColor(color: Int, activity: Activity) {
150     activity
151       .window
152       .addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
153     activity
154       .window.statusBarColor = color
155   }
156 
157   @UiThread
158   fun setTranslucent(translucent: Boolean, activity: Activity) {
159     // If the status bar is translucent hook into the window insets calculations
160     // and consume all the top insets so no padding will be added under the status bar.
161     val decorView = activity.window.decorView
162     if (translucent) {
163       decorView.setOnApplyWindowInsetsListener { v: View, insets: WindowInsets? ->
164         val defaultInsets = v.onApplyWindowInsets(insets)
165         defaultInsets.replaceSystemWindowInsets(
166           defaultInsets.systemWindowInsetLeft,
167           0,
168           defaultInsets.systemWindowInsetRight,
169           defaultInsets.systemWindowInsetBottom
170         )
171       }
172     } else {
173       decorView.setOnApplyWindowInsetsListener(null)
174     }
175     ViewCompat.requestApplyInsets(decorView)
176   }
177 
178   /**
179    * @return Effective style that is actually applied to the status bar.
180    */
181   @UiThread
182   private fun setStyle(style: String?, activity: Activity): String {
183     var appliedStatusBarStyle = STATUS_BAR_STYLE_LIGHT_CONTENT
184     if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
185       val decorView = activity.window.decorView
186       var systemUiVisibilityFlags = decorView.systemUiVisibility
187       when (style) {
188         STATUS_BAR_STYLE_LIGHT_CONTENT -> {
189           systemUiVisibilityFlags =
190             systemUiVisibilityFlags and View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv()
191           appliedStatusBarStyle = STATUS_BAR_STYLE_LIGHT_CONTENT
192         }
193         STATUS_BAR_STYLE_DARK_CONTENT -> {
194           systemUiVisibilityFlags = systemUiVisibilityFlags or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
195           appliedStatusBarStyle = STATUS_BAR_STYLE_DARK_CONTENT
196         }
197         else -> {
198           systemUiVisibilityFlags = systemUiVisibilityFlags or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
199           appliedStatusBarStyle = STATUS_BAR_STYLE_DARK_CONTENT
200         }
201       }
202       decorView.systemUiVisibility = systemUiVisibilityFlags
203     }
204     return appliedStatusBarStyle
205   }
206 
207   @UiThread
208   private fun setHidden(hidden: Boolean, activity: Activity) {
209     if (hidden) {
210       activity.window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
211       activity.window.clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN)
212     } else {
213       activity.window.addFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN)
214       activity.window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
215     }
216   }
217 
218   // endregion
219 
220   fun setTaskDescription(
221     exponentManifest: ExponentManifest,
222     manifest: Manifest,
223     activity: Activity
224   ) {
225     val iconUrl = manifest.getIconUrl()
226     val color = exponentManifest.getColorFromManifest(manifest)
227     exponentManifest.loadIconBitmap(
228       iconUrl,
229       object : BitmapListener {
230         override fun onLoadBitmap(bitmap: Bitmap?) {
231           // This if statement is only needed so the compiler doesn't show an error.
232           try {
233             activity.setTaskDescription(
234               TaskDescription(
235                 manifest.getName() ?: "",
236                 bitmap,
237                 color
238               )
239             )
240           } catch (e: Throwable) {
241             EXL.e(TAG, e)
242           }
243         }
244       }
245     )
246   }
247 
248   fun setNavigationBar(manifest: Manifest, activity: Activity) {
249     val navBarOptions = manifest.getAndroidNavigationBarOptions() ?: return
250 
251     // Set background color of navigation bar
252     val navBarColor = if (navBarOptions.has(ExponentManifest.MANIFEST_NAVIGATION_BAR_BACKGROUND_COLOR)) {
253       navBarOptions.optString(ExponentManifest.MANIFEST_NAVIGATION_BAR_BACKGROUND_COLOR)
254     } else null
255     if (navBarColor != null && ColorParser.isValid(navBarColor)) {
256       try {
257         activity.window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION)
258         activity.window.navigationBarColor = Color.parseColor(navBarColor)
259       } catch (e: Throwable) {
260         EXL.e(TAG, e)
261       }
262     }
263 
264     // Set icon color of navigation bar
265     val navBarAppearance = if (navBarOptions.has(ExponentManifest.MANIFEST_NAVIGATION_BAR_APPEARANCE)) {
266       navBarOptions.optString(ExponentManifest.MANIFEST_NAVIGATION_BAR_APPEARANCE)
267     } else null
268     if (navBarAppearance != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
269       try {
270         activity.window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION)
271         activity.window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
272         if (navBarAppearance == "dark-content") {
273           val decorView = activity.window.decorView
274           var flags = decorView.systemUiVisibility
275           flags = flags or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR
276           decorView.systemUiVisibility = flags
277         }
278       } catch (e: Throwable) {
279         EXL.e(TAG, e)
280       }
281     }
282 
283     // Set visibility of navigation bar
284     val navBarVisible = if (navBarOptions.has(ExponentManifest.MANIFEST_NAVIGATION_BAR_VISIBLILITY)) {
285       navBarOptions.optString(ExponentManifest.MANIFEST_NAVIGATION_BAR_VISIBLILITY)
286     } else null
287     if (navBarVisible != null) {
288       // Hide both the navigation bar and the status bar. The Android docs recommend, "you should
289       // design your app to hide the status bar whenever you hide the navigation bar."
290       val decorView = activity.window.decorView
291       var flags = decorView.systemUiVisibility
292       when (navBarVisible) {
293         "leanback" ->
294           flags =
295             flags or (View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_FULLSCREEN)
296         "immersive" ->
297           flags =
298             flags or (View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_IMMERSIVE)
299         "sticky-immersive" ->
300           flags =
301             flags or (View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY)
302       }
303       decorView.systemUiVisibility = flags
304     }
305   }
306 
307   fun setRootViewBackgroundColor(manifest: Manifest, rootView: View) {
308     var colorString = manifest.getAndroidBackgroundColor()
309     if (colorString == null || !ColorParser.isValid(colorString)) {
310       colorString = "#ffffff"
311     }
312     try {
313       val color = Color.parseColor(colorString)
314       rootView.setBackgroundColor(color)
315     } catch (e: Throwable) {
316       EXL.e(TAG, e)
317       rootView.setBackgroundColor(Color.WHITE)
318     }
319   }
320 }
321