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