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