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