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 val kernel: Kernel? = null
46 
47   @Inject
48   internal val exponentSharedPreferences: ExponentSharedPreferences? = null
49 
50   init {
51     NativeModuleDepsProvider.getInstance().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    */
62   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")
72   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    */
103   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    */
126   fun hideInCurrentActivity() {
127     val currentActivity = ExperienceActivity.getCurrentActivity()
128 
129     if (currentActivity != null) {
130       hideInActivity(currentActivity)
131     }
132   }
133 
134   /**
135    * Toggles dev menu visibility in given experience activity.
136    */
137   fun toggleInActivity(activity: ExperienceActivity) {
138     if (isDevMenuVisible() && activity.hasReactView(reactRootView)) {
139       requestToClose(activity)
140     } else {
141       showInActivity(activity)
142     }
143   }
144 
145   /**
146    * Requests JavaScript side to start closing the dev menu (start the animation or so).
147    * Fully closes the dev menu once it receives a response from that event.
148    */
149   fun requestToClose(activity: ExperienceActivity) {
150     if (Constants.isStandaloneApp()) {
151       return
152     }
153 
154     ExponentKernelModule.queueEvent("ExponentKernel.requestToCloseDevMenu", Arguments.createMap(), object : ExponentKernelModuleProvider.KernelEventCallback {
155       override fun onEventSuccess(result: ReadableMap) {
156         hideInActivity(activity)
157       }
158 
159       override fun onEventFailure(errorMessage: String) {
160         hideInActivity(activity)
161       }
162     })
163   }
164 
165   /**
166    * Simplified version of the above function, but operates on the current experience activity.
167    */
168   fun requestToClose() {
169     getCurrentExperienceActivity()?.let {
170       requestToClose(it)
171     }
172   }
173 
174   /**
175    * Gets a map of dev menu options available in the currently shown [ExperienceActivity].
176    * If the experience doesn't support developer tools just returns an empty response.
177    */
178   fun getMenuItems(): WritableMap {
179     val devMenuModule = getCurrentDevMenuModule()
180     val menuItemsBundle = devMenuModule?.getMenuItems()
181 
182     return if (menuItemsBundle != null && devMenuModule.isDevSupportEnabled()) {
183       Arguments.fromBundle(menuItemsBundle)
184     } else {
185       Arguments.createMap()
186     }
187   }
188 
189   /**
190    * Function called every time the dev menu option is selected. It passes this request down
191    * to the specific [DevMenuModule] that is linked with the currently shown [ExperienceActivity].
192    */
193   fun selectItemWithKey(itemKey: String) {
194     getCurrentDevMenuModule()?.selectItemWithKey(itemKey)
195   }
196 
197   /**
198    * Reloads app with the manifest, falls back to reloading just JS bundle if reloading manifest fails.
199    */
200   fun reloadApp() {
201     getCurrentDevMenuModule()?.let {
202       try {
203         val manifestUrl = it.getManifestUrl()
204         kernel?.reloadVisibleExperience(manifestUrl, false)
205       } catch (reloadingException: Exception) {
206         reloadingException.printStackTrace()
207         // If anything goes wrong here, we can fall back to plain JS reload.
208         it.reloadApp()
209       }
210     }
211   }
212 
213   /**
214    * Returns boolean value determining whether the current app supports developer tools.
215    */
216   fun isDevSupportEnabledByCurrentActivity(): Boolean {
217     val devMenuModule = getCurrentDevMenuModule()
218     return devMenuModule?.isDevSupportEnabled() ?: false
219   }
220 
221   /**
222    * Checks whether the dev menu is shown over given experience activity.
223    */
224   fun isShownInActivity(activity: ExperienceActivity): Boolean {
225     return reactRootView != null && activity.hasReactView(reactRootView)
226   }
227 
228   /**
229    * Checks whether the dev menu onboarding is already finished.
230    * Onboarding is a screen that shows the dev menu to the user that opens any experience for the first time.
231    */
232   fun isOnboardingFinished(): Boolean {
233     return exponentSharedPreferences?.getBoolean(ExponentSharedPreferences.IS_ONBOARDING_FINISHED_KEY) ?: false
234   }
235 
236   /**
237    * Sets appropriate setting in shared preferences that user's onboarding has finished.
238    */
239   fun setIsOnboardingFinished(isOnboardingFinished: Boolean = true) {
240     exponentSharedPreferences?.setBoolean(ExponentSharedPreferences.IS_ONBOARDING_FINISHED_KEY, isOnboardingFinished)
241   }
242 
243   /**
244    * In case the user switches from [host.exp.exponent.experience.HomeActivity] to [ExperienceActivity] which has a visible dev menu,
245    * we need to call onHostResume on the kernel's react instance manager to change its current activity.
246    */
247   fun maybeResumeHostWithActivity(activity: ExperienceActivity) {
248     if (isShownInActivity(activity)) {
249       kernel?.reactInstanceManager?.onHostResume(activity)
250     }
251   }
252 
253   /**
254    * Receives events of type [ReactNativeActivity.ExperienceDoneLoadingEvent] once the experience finishes loading.
255    */
256   fun onEvent(event: ReactNativeActivity.ExperienceDoneLoadingEvent) {
257     (event.activity as? ExperienceActivity)?.let {
258       maybeShowWithOnboarding(it)
259     }
260   }
261 
262   //endregion publics
263   //region internals
264 
265   /**
266    * Says whether the dev menu should show onboarding view if this is the first time
267    * the user opens an experience, or he hasn't finished onboarding yet.
268    */
269   private fun shouldShowOnboarding(): Boolean {
270     return !Constants.isStandaloneApp() && !KernelConfig.HIDE_ONBOARDING && !isOnboardingFinished() && !Constants.DISABLE_NUX
271   }
272 
273   /**
274    * Shows dev menu in given activity but only when the onboarding view should show up.
275    */
276   private fun maybeShowWithOnboarding(activity: ExperienceActivity) {
277     if (shouldShowOnboarding() && !isShownInActivity(activity)) {
278       // @tsapeta: We need a small delay to allow the experience to be fully rendered.
279       // Without the delay we were having some weird issues with style props being set on nonexistent shadow views.
280       // From the other side, it's good that we don't show it immediately so the user can see his app first.
281       Handler().postDelayed({ showInActivity(activity) }, 2000)
282     }
283   }
284 
285   /**
286    * Starts [ShakeDetector] if it's not running yet.
287    */
288   private fun maybeStartDetectingShakes(context: Context) {
289     if (shakeDetector != null) {
290       return
291     }
292     shakeDetector = ShakeDetector { this.onShakeGesture() }
293     shakeDetector?.start(context.getSystemService(Context.SENSOR_SERVICE) as SensorManager)
294   }
295 
296   /**
297    * If this is the first time when we're going to show the dev menu, it creates a new react root view
298    * that will render the other endpoint of home app whose name is described by [DEV_MENU_JS_MODULE_NAME] constant.
299    * Also sets initialProps, layout settings and initial animation values.
300    */
301   @Throws(Exception::class)
302   private fun prepareRootView(initialProps: Bundle): ReactRootView {
303     // Throw an exception in case the kernel is not initialized yet.
304     if (kernel?.reactInstanceManager == null) {
305       throw Exception("Kernel's React instance manager is not initialized yet.")
306     }
307 
308     if (reactRootView == null) {
309       reactRootView = ReactUnthemedRootView(kernel.activityContext)
310       reactRootView?.startReactApplication(kernel.reactInstanceManager, DEV_MENU_JS_MODULE_NAME, initialProps)
311     } else {
312       reactRootView?.appProperties = initialProps
313     }
314 
315     val rootView = reactRootView!!
316 
317     rootView.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
318     rootView.visibility = View.VISIBLE
319 
320     return rootView
321   }
322 
323   /**
324    * Loses view focus in given activity. It makes sure that system's keyboard is hidden when presenting dev menu view.
325    */
326   private fun loseFocusInActivity(activity: ExperienceActivity) {
327     activity.getCurrentFocus()?.clearFocus()
328   }
329 
330   /**
331    * Returns an instance implementing [DevMenuModuleInterface] linked to the current [ExperienceActivity], or null if the current
332    * activity is not of [ExperienceActivity] type or there is no module registered for that activity.
333    */
334   private fun getCurrentDevMenuModule(): DevMenuModuleInterface? {
335     val currentActivity = getCurrentExperienceActivity()
336     return if (currentActivity != null) devMenuModulesRegistry[currentActivity] else null
337   }
338 
339   /**
340    * Returns current activity if it's of type [ExperienceActivity], or null otherwise.
341    */
342   private fun getCurrentExperienceActivity(): ExperienceActivity? {
343     return ExperienceActivity.getCurrentActivity()
344   }
345 
346   /**
347    * Checks whether the dev menu is visible anywhere.
348    */
349   private fun isDevMenuVisible(): Boolean {
350     return reactRootView?.parent != null
351   }
352 
353   /**
354    * Handles shake gesture which simply toggles the dev menu.
355    */
356   private fun onShakeGesture() {
357     val currentActivity = ExperienceActivity.getCurrentActivity()
358 
359     if (currentActivity != null) {
360       toggleInActivity(currentActivity)
361     }
362   }
363 
364   private fun tryToPauseHostActivity(activity: ExperienceActivity) {
365     try {
366       kernel?.reactInstanceManager?.onHostPause(activity)
367     } catch (e: AssertionError) {
368       // nothing
369     }
370   }
371 
372   //endregion internals
373 }
374