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