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