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