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