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