1 package host.exp.exponent.kernel
2 
3 import android.annotation.SuppressLint
4 import android.content.Context
5 import android.content.pm.ActivityInfo
6 import android.hardware.SensorManager
7 import android.os.Bundle
8 import android.os.Handler
9 import android.util.Log
10 import android.view.View
11 import android.view.ViewGroup
12 import com.facebook.react.ReactRootView
13 import com.facebook.react.bridge.Arguments
14 import com.facebook.react.bridge.ReadableMap
15 import com.facebook.react.bridge.UiThreadUtil
16 import com.facebook.react.bridge.WritableMap
17 import de.greenrobot.event.EventBus
18 import host.exp.exponent.utils.ShakeDetector
19 import host.exp.exponent.Constants
20 import versioned.host.exp.exponent.modules.internal.DevMenuModule
21 import host.exp.exponent.di.NativeModuleDepsProvider
22 import host.exp.exponent.experience.ExperienceActivity
23 import host.exp.exponent.experience.ReactNativeActivity
24 import versioned.host.exp.exponent.ReactUnthemedRootView
25 import java.util.*
26 import javax.inject.Inject
27 import host.exp.exponent.modules.ExponentKernelModule
28 import host.exp.exponent.storage.ExponentSharedPreferences
29 
30 private const val DEV_MENU_JS_MODULE_NAME = "HomeMenu"
31 
32 /**
33  * DevMenuManager is like a singleton that manages the dev menu in the whole application
34  * and delegates calls from [ExponentKernelModule] to the specific [DevMenuModule]
35  * that is linked with a react context for which the dev menu is going to be rendered.
36  * Its instance can be injected as a dependency of other classes by [NativeModuleDepsProvider]
37  */
38 class DevMenuManager {
39   private var shakeDetector: ShakeDetector? = null
40   private var reactRootView: ReactRootView? = null
41   private var orientationBeforeShowingDevMenu: Int = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
42   private val devMenuModulesRegistry = WeakHashMap<ExperienceActivity, DevMenuModuleInterface>()
43 
44   @Inject
45   internal lateinit var kernel: Kernel
46 
47   @Inject
48   internal lateinit var exponentSharedPreferences: ExponentSharedPreferences
49 
50   init {
51     NativeModuleDepsProvider.instance.inject(DevMenuManager::class.java, this)
52     EventBus.getDefault().register(this)
53   }
54 
55   //region publics
56 
57   /**
58    * Links given [DevMenuModule] with given [ExperienceActivity]. [DevMenuManager] needs to know this to
59    * get appropriate data or pass requests down to the correct [DevMenuModule] that handles
60    * all these stuff for a specific experience (DevMenuManager only delegates those calls).
61    */
registerDevMenuModuleForActivitynull62   fun registerDevMenuModuleForActivity(devMenuModule: DevMenuModuleInterface, activity: ExperienceActivity) {
63     // Start shake detector once the first DevMenuModule registers in the manager.
64     maybeStartDetectingShakes(activity.applicationContext)
65     devMenuModulesRegistry[activity] = devMenuModule
66   }
67 
68   /**
69    * Shows dev menu in given experience activity. Ensures it never happens in standalone apps and is run on the UI thread.
70    */
71   @SuppressLint("SourceLockedOrientationActivity")
showInActivitynull72   fun showInActivity(activity: ExperienceActivity) {
73     if (Constants.isStandaloneApp()) {
74       return
75     }
76 
77     UiThreadUtil.runOnUiThread {
78       try {
79         val devMenuModule = devMenuModulesRegistry[activity] ?: return@runOnUiThread
80         val devMenuView = prepareRootView(devMenuModule.getInitialProps())
81 
82         loseFocusInActivity(activity)
83 
84         // We need to force the device to use portrait orientation as the dev menu doesn't support landscape.
85         // However, when removing it, we should set it back to the orientation from before showing the dev menu.
86         orientationBeforeShowingDevMenu = activity.requestedOrientation
87         activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
88 
89         activity.addReactViewToContentContainer(devMenuView)
90 
91         // @tsapeta: We need to call onHostResume on kernel's react instance with the new ExperienceActivity.
92         // Otherwise, touches and other gestures may not work correctly.
93         kernel.reactInstanceManager?.onHostResume(activity)
94       } catch (exception: Exception) {
95         Log.e("ExpoDevMenu", exception.message ?: "No error message.")
96       }
97     }
98   }
99 
100   /**
101    * Hides dev menu in given experience activity. Ensures it never happens in standalone apps and is run on the UI thread.
102    */
hideInActivitynull103   fun hideInActivity(activity: ExperienceActivity) {
104     if (Constants.isStandaloneApp()) {
105       return
106     }
107 
108     UiThreadUtil.runOnUiThread {
109       reactRootView?.let {
110         val parentView = it.parent as ViewGroup?
111 
112         // Restore the original orientation that had been set before the dev menu was displayed.
113         activity.requestedOrientation = orientationBeforeShowingDevMenu
114 
115         it.visibility = View.GONE
116         parentView?.removeView(it)
117         tryToPauseHostActivity(activity)
118       }
119     }
120   }
121 
122   /**
123    * Hides dev menu in the currently shown experience activity.
124    * Does nothing if the current activity is not of type [ExperienceActivity].
125    */
hideInCurrentActivitynull126   fun hideInCurrentActivity() {
127     val currentActivity = ExperienceActivity.currentActivity
128     if (currentActivity != null) {
129       hideInActivity(currentActivity)
130     }
131   }
132 
133   /**
134    * Toggles dev menu visibility in given experience activity.
135    */
toggleInActivitynull136   fun toggleInActivity(activity: ExperienceActivity) {
137     if (isDevMenuVisible() && reactRootView != null && activity.hasReactView(reactRootView!!)) {
138       requestToClose(activity)
139     } else {
140       showInActivity(activity)
141     }
142   }
143 
144   /**
145    * Requests JavaScript side to start closing the dev menu (start the animation or so).
146    * Fully closes the dev menu once it receives a response from that event.
147    */
requestToClosenull148   fun requestToClose(activity: ExperienceActivity) {
149     if (Constants.isStandaloneApp()) {
150       return
151     }
152 
153     ExponentKernelModule.queueEvent(
154       "ExponentKernel.requestToCloseDevMenu", Arguments.createMap(),
155       object : ExponentKernelModuleProvider.KernelEventCallback {
156         override fun onEventSuccess(result: ReadableMap) {
157           hideInActivity(activity)
158         }
159 
160         override fun onEventFailure(errorMessage: String?) {
161           hideInActivity(activity)
162         }
163       }
164     )
165   }
166 
167   /**
168    * Simplified version of the above function, but operates on the current experience activity.
169    */
requestToClosenull170   fun requestToClose() {
171     getCurrentExperienceActivity()?.let {
172       requestToClose(it)
173     }
174   }
175 
176   /**
177    * Gets a map of dev menu options available in the currently shown [ExperienceActivity].
178    * If the experience doesn't support developer tools just returns an empty response.
179    */
getMenuItemsnull180   fun getMenuItems(): WritableMap {
181     val devMenuModule = getCurrentDevMenuModule()
182     val menuItemsBundle = devMenuModule?.getMenuItems()
183 
184     return if (menuItemsBundle != null && devMenuModule.isDevSupportEnabled()) {
185       Arguments.fromBundle(menuItemsBundle)
186     } else {
187       Arguments.createMap()
188     }
189   }
190 
191   /**
192    * Function called every time the dev menu option is selected. It passes this request down
193    * to the specific [DevMenuModule] that is linked with the currently shown [ExperienceActivity].
194    */
selectItemWithKeynull195   fun selectItemWithKey(itemKey: String) {
196     getCurrentDevMenuModule()?.selectItemWithKey(itemKey)
197   }
198 
199   /**
200    * Reloads app with the manifest, falls back to reloading just JS bundle if reloading manifest fails.
201    */
reloadAppnull202   fun reloadApp() {
203     getCurrentDevMenuModule()?.let {
204       try {
205         val manifestUrl = it.getManifestUrl()
206         kernel.reloadVisibleExperience(manifestUrl, false)
207       } catch (reloadingException: Exception) {
208         reloadingException.printStackTrace()
209         // If anything goes wrong here, we can fall back to plain JS reload.
210         it.reloadApp()
211       }
212     }
213   }
214 
215   /**
216    * Returns boolean value determining whether the current app supports developer tools.
217    */
isDevSupportEnabledByCurrentActivitynull218   fun isDevSupportEnabledByCurrentActivity(): Boolean {
219     val devMenuModule = getCurrentDevMenuModule()
220     return devMenuModule?.isDevSupportEnabled() ?: false
221   }
222 
223   /**
224    * Checks whether the dev menu is shown over given experience activity.
225    */
isShownInActivitynull226   fun isShownInActivity(activity: ExperienceActivity): Boolean {
227     return reactRootView != null && activity.hasReactView(reactRootView!!)
228   }
229 
230   /**
231    * Checks whether the dev menu onboarding is already finished.
232    * Onboarding is a screen that shows the dev menu to the user that opens any experience for the first time.
233    */
isOnboardingFinishednull234   fun isOnboardingFinished(): Boolean {
235     return exponentSharedPreferences.getBoolean(ExponentSharedPreferences.ExponentSharedPreferencesKey.IS_ONBOARDING_FINISHED_KEY) ?: false
236   }
237 
238   /**
239    * Sets appropriate setting in shared preferences that user's onboarding has finished.
240    */
setIsOnboardingFinishednull241   fun setIsOnboardingFinished(isOnboardingFinished: Boolean = true) {
242     exponentSharedPreferences.setBoolean(ExponentSharedPreferences.ExponentSharedPreferencesKey.IS_ONBOARDING_FINISHED_KEY, isOnboardingFinished)
243   }
244 
245   /**
246    * In case the user switches from [host.exp.exponent.experience.HomeActivity] to [ExperienceActivity] which has a visible dev menu,
247    * we need to call onHostResume on the kernel's react instance manager to change its current activity.
248    */
maybeResumeHostWithActivitynull249   fun maybeResumeHostWithActivity(activity: ExperienceActivity) {
250     if (isShownInActivity(activity)) {
251       kernel.reactInstanceManager?.onHostResume(activity)
252     }
253   }
254 
255   /**
256    * Receives events of type [ReactNativeActivity.ExperienceDoneLoadingEvent] once the experience finishes loading.
257    */
onEventnull258   fun onEvent(event: ReactNativeActivity.ExperienceDoneLoadingEvent) {
259     (event.activity as? ExperienceActivity)?.let {
260       maybeShowWithOnboarding(it)
261     }
262   }
263 
264   //endregion publics
265   //region internals
266 
267   /**
268    * Says whether the dev menu should show onboarding view if this is the first time
269    * the user opens an experience, or he hasn't finished onboarding yet.
270    */
shouldShowOnboardingnull271   private fun shouldShowOnboarding(): Boolean {
272     return !Constants.isStandaloneApp() && !KernelConfig.HIDE_ONBOARDING && !isOnboardingFinished() && !Constants.DISABLE_NUX
273   }
274 
275   /**
276    * Shows dev menu in given activity but only when the onboarding view should show up.
277    */
maybeShowWithOnboardingnull278   private fun maybeShowWithOnboarding(activity: ExperienceActivity) {
279     if (shouldShowOnboarding() && !isShownInActivity(activity)) {
280       // @tsapeta: We need a small delay to allow the experience to be fully rendered.
281       // Without the delay we were having some weird issues with style props being set on nonexistent shadow views.
282       // From the other side, it's good that we don't show it immediately so the user can see his app first.
283       Handler().postDelayed({ showInActivity(activity) }, 2000)
284     }
285   }
286 
287   /**
288    * Starts [ShakeDetector] if it's not running yet.
289    */
maybeStartDetectingShakesnull290   private fun maybeStartDetectingShakes(context: Context) {
291     if (shakeDetector != null) {
292       return
293     }
294     shakeDetector = ShakeDetector { this.onShakeGesture() }
295     shakeDetector?.start(context.getSystemService(Context.SENSOR_SERVICE) as SensorManager)
296   }
297 
298   /**
299    * If this is the first time when we're going to show the dev menu, it creates a new react root view
300    * that will render the other endpoint of home app whose name is described by [DEV_MENU_JS_MODULE_NAME] constant.
301    * Also sets initialProps, layout settings and initial animation values.
302    */
303   @Throws(Exception::class)
prepareRootViewnull304   private fun prepareRootView(initialProps: Bundle): ReactRootView {
305     // Throw an exception in case the kernel is not initialized yet.
306     if (kernel.reactInstanceManager == null) {
307       throw Exception("Kernel's React instance manager is not initialized yet.")
308     }
309 
310     if (reactRootView == null) {
311       reactRootView = ReactUnthemedRootView(kernel.activityContext)
312       reactRootView?.startReactApplication(kernel.reactInstanceManager, DEV_MENU_JS_MODULE_NAME, initialProps)
313     } else {
314       reactRootView?.appProperties = initialProps
315     }
316 
317     val rootView = reactRootView!!
318 
319     rootView.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
320     rootView.visibility = View.VISIBLE
321 
322     return rootView
323   }
324 
325   /**
326    * Loses view focus in given activity. It makes sure that system's keyboard is hidden when presenting dev menu view.
327    */
loseFocusInActivitynull328   private fun loseFocusInActivity(activity: ExperienceActivity) {
329     activity.getCurrentFocus()?.clearFocus()
330   }
331 
332   /**
333    * Returns an instance implementing [DevMenuModuleInterface] linked to the current [ExperienceActivity], or null if the current
334    * activity is not of [ExperienceActivity] type or there is no module registered for that activity.
335    */
getCurrentDevMenuModulenull336   private fun getCurrentDevMenuModule(): DevMenuModuleInterface? {
337     val currentActivity = getCurrentExperienceActivity()
338     return if (currentActivity != null) devMenuModulesRegistry[currentActivity] else null
339   }
340 
341   /**
342    * Returns current activity if it's of type [ExperienceActivity], or null otherwise.
343    */
getCurrentExperienceActivitynull344   private fun getCurrentExperienceActivity(): ExperienceActivity? {
345     return ExperienceActivity.currentActivity
346   }
347 
348   /**
349    * Checks whether the dev menu is visible anywhere.
350    */
isDevMenuVisiblenull351   private fun isDevMenuVisible(): Boolean {
352     return reactRootView?.parent != null
353   }
354 
355   /**
356    * Handles shake gesture which simply toggles the dev menu.
357    */
onShakeGesturenull358   private fun onShakeGesture() {
359     val currentActivity = ExperienceActivity.currentActivity
360 
361     if (currentActivity != null) {
362       toggleInActivity(currentActivity)
363     }
364   }
365 
tryToPauseHostActivitynull366   private fun tryToPauseHostActivity(activity: ExperienceActivity) {
367     try {
368       kernel.reactInstanceManager?.onHostPause(activity)
369     } catch (e: AssertionError) {
370       // nothing
371     }
372   }
373 
374   //endregion internals
375 }
376