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