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