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.util.Log 9 import android.view.View 10 import android.view.ViewGroup 11 import com.facebook.react.ReactRootView 12 import com.facebook.react.bridge.Arguments 13 import com.facebook.react.bridge.ReadableMap 14 import com.facebook.react.bridge.UiThreadUtil 15 import com.facebook.react.bridge.WritableMap 16 import host.exp.exponent.utils.ShakeDetector 17 import host.exp.exponent.Constants 18 import versioned.host.exp.exponent.modules.internal.DevMenuModule 19 import host.exp.exponent.di.NativeModuleDepsProvider 20 import host.exp.exponent.experience.ExperienceActivity 21 import versioned.host.exp.exponent.ReactUnthemedRootView 22 import java.util.* 23 import javax.inject.Inject 24 import host.exp.exponent.modules.ExponentKernelModule 25 import host.exp.exponent.storage.ExponentSharedPreferences 26 27 28 private const val DEV_MENU_JS_MODULE_NAME = "HomeMenu" 29 30 /** 31 * DevMenuManager is like a singleton that manages the dev menu in the whole application 32 * and delegates calls from [ExponentKernelModule] to the specific [DevMenuModule] 33 * that is linked with a react context for which the dev menu is going to be rendered. 34 * Its instance can be injected as a dependency of other classes by [NativeModuleDepsProvider] 35 */ 36 class DevMenuManager { 37 private var shakeDetector: ShakeDetector? = null 38 private var reactRootView: ReactRootView? = null 39 private var orientationBeforeShowingDevMenu: Int = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED 40 private val devMenuModulesRegistry = WeakHashMap<ExperienceActivity, DevMenuModuleInterface>() 41 42 @Inject 43 internal val kernel: Kernel? = null 44 45 @Inject 46 internal val exponentSharedPreferences: ExponentSharedPreferences? = null 47 48 init { 49 NativeModuleDepsProvider.getInstance().inject(DevMenuManager::class.java, this) 50 } 51 52 //region publics 53 54 /** 55 * Links given [DevMenuModule] with given [ExperienceActivity]. [DevMenuManager] needs to know this to 56 * get appropriate data or pass requests down to the correct [DevMenuModule] that handles 57 * all these stuff for a specific experience (DevMenuManager only delegates those calls). 58 */ 59 fun registerDevMenuModuleForActivity(devMenuModule: DevMenuModuleInterface, activity: ExperienceActivity) { 60 // Start shake detector once the first DevMenuModule registers in the manager. 61 maybeStartDetectingShakes(activity.applicationContext) 62 devMenuModulesRegistry[activity] = devMenuModule 63 } 64 65 /** 66 * Shows dev menu in given experience activity. Ensures it never happens in standalone apps and is run on the UI thread. 67 */ 68 @SuppressLint("SourceLockedOrientationActivity") 69 fun showInActivity(activity: ExperienceActivity) { 70 if (Constants.isStandaloneApp()) { 71 return 72 } 73 74 UiThreadUtil.runOnUiThread { 75 try { 76 val devMenuModule = devMenuModulesRegistry[activity] ?: return@runOnUiThread 77 val devMenuView = prepareRootView(devMenuModule.getInitialProps()) 78 79 // We need to force the device to use portrait orientation as the dev menu doesn't support landscape. 80 // However, when removing it, we should set it back to the orientation from before showing the dev menu. 81 orientationBeforeShowingDevMenu = activity.requestedOrientation 82 activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT 83 84 activity.addView(devMenuView) 85 86 // @tsapeta: We need to call onHostResume on kernel's react instance with the new ExperienceActivity. 87 // Otherwise, touches and other gestures may not work correctly. 88 kernel?.reactInstanceManager?.onHostResume(activity) 89 } catch (exception: Exception) { 90 Log.e("ExpoDevMenu", exception.message) 91 } 92 } 93 } 94 95 /** 96 * Hides dev menu in given experience activity. Ensures it never happens in standalone apps and is run on the UI thread. 97 */ 98 fun hideInActivity(activity: ExperienceActivity) { 99 if (Constants.isStandaloneApp()) { 100 return 101 } 102 103 UiThreadUtil.runOnUiThread { 104 reactRootView?.let { 105 val parentView = it.parent as ViewGroup? 106 107 // Restore the original orientation that had been set before the dev menu was displayed. 108 activity.requestedOrientation = orientationBeforeShowingDevMenu 109 110 it.visibility = View.GONE 111 parentView?.removeView(it) 112 tryToPauseHostActivity(activity) 113 } 114 } 115 } 116 117 /** 118 * Hides dev menu in the currently shown experience activity. 119 * Does nothing if the current activity is not of type [ExperienceActivity]. 120 */ 121 fun hideInCurrentActivity() { 122 val currentActivity = ExperienceActivity.getCurrentActivity() 123 124 if (currentActivity != null) { 125 hideInActivity(currentActivity) 126 } 127 } 128 129 /** 130 * Toggles dev menu visibility in given experience activity. 131 */ 132 fun toggleInActivity(activity: ExperienceActivity) { 133 if (isDevMenuVisible() && activity.hasView(reactRootView)) { 134 requestToClose(activity) 135 } else { 136 showInActivity(activity) 137 } 138 } 139 140 /** 141 * Requests JavaScript side to start closing the dev menu (start the animation or so). 142 * Fully closes the dev menu once it receives a response from that event. 143 */ 144 fun requestToClose(activity: ExperienceActivity) { 145 if (Constants.isStandaloneApp()) { 146 return 147 } 148 149 ExponentKernelModule.queueEvent("ExponentKernel.requestToCloseDevMenu", Arguments.createMap(), object : ExponentKernelModuleProvider.KernelEventCallback { 150 override fun onEventSuccess(result: ReadableMap) { 151 hideInActivity(activity) 152 } 153 154 override fun onEventFailure(errorMessage: String) { 155 hideInActivity(activity) 156 } 157 }) 158 } 159 160 /** 161 * Simplified version of the above function, but operates on the current experience activity. 162 */ 163 fun requestToClose() { 164 getCurrentExperienceActivity()?.let { 165 requestToClose(it) 166 } 167 } 168 169 /** 170 * Gets a map of dev menu options available in the currently shown [ExperienceActivity]. 171 * If the experience doesn't support developer tools just returns an empty response. 172 */ 173 fun getMenuItems(): WritableMap { 174 val devMenuModule = getCurrentDevMenuModule() 175 val menuItemsBundle = devMenuModule?.getMenuItems() 176 177 return if (menuItemsBundle != null && devMenuModule.isDevSupportEnabled()) { 178 Arguments.fromBundle(menuItemsBundle) 179 } else { 180 Arguments.createMap() 181 } 182 } 183 184 /** 185 * Function called every time the dev menu option is selected. It passes this request down 186 * to the specific [DevMenuModule] that is linked with the currently shown [ExperienceActivity]. 187 */ 188 fun selectItemWithKey(itemKey: String) { 189 getCurrentDevMenuModule()?.selectItemWithKey(itemKey) 190 } 191 192 /** 193 * Reloads app with the manifest, falls back to reloading just JS bundle if reloading manifest fails. 194 */ 195 fun reloadApp() { 196 getCurrentDevMenuModule()?.let { 197 try { 198 val manifestUrl = it.getManifestUrl() 199 kernel?.reloadVisibleExperience(manifestUrl, false) 200 } catch (reloadingException: Exception) { 201 reloadingException.printStackTrace() 202 // If anything goes wrong here, we can fall back to plain JS reload. 203 it.reloadApp() 204 } 205 } 206 } 207 208 /** 209 * Returns boolean value determining whether the current app supports developer tools. 210 */ 211 fun isDevSupportEnabledByCurrentActivity(): Boolean { 212 val devMenuModule = getCurrentDevMenuModule() 213 return devMenuModule?.isDevSupportEnabled() ?: false 214 } 215 216 /** 217 * Checks whether the dev menu is shown over given experience activity. 218 */ 219 fun isShownInActivity(activity: ExperienceActivity): Boolean { 220 return reactRootView != null && activity.hasView(reactRootView) 221 } 222 223 /** 224 * Checks whether the dev menu onboarding is already finished. 225 * Onboarding is a screen that shows the dev menu to the user that opens any experience for the first time. 226 */ 227 fun isOnboardingFinished(): Boolean { 228 return exponentSharedPreferences?.getBoolean(ExponentSharedPreferences.IS_ONBOARDING_FINISHED_KEY) ?: false 229 } 230 231 /** 232 * Sets appropriate setting in shared preferences that user's onboarding has finished. 233 */ 234 fun setIsOnboardingFinished(isOnboardingFinished: Boolean = true) { 235 exponentSharedPreferences?.setBoolean(ExponentSharedPreferences.IS_ONBOARDING_FINISHED_KEY, isOnboardingFinished) 236 } 237 238 /** 239 * In case the user switches from [host.exp.exponent.experience.HomeActivity] to [ExperienceActivity] which has a visible dev menu, 240 * we need to call onHostResume on the kernel's react instance manager to change its current activity. 241 */ 242 fun maybeResumeHostWithActivity(activity: ExperienceActivity) { 243 if (isShownInActivity(activity)) { 244 kernel?.reactInstanceManager?.onHostResume(activity) 245 } 246 } 247 248 //endregion publics 249 //region internals 250 251 /** 252 * Starts [ShakeDetector] if it's not running yet. 253 */ 254 private fun maybeStartDetectingShakes(context: Context) { 255 if (shakeDetector != null) { 256 return 257 } 258 shakeDetector = ShakeDetector { this.onShakeGesture() } 259 shakeDetector?.start(context.getSystemService(Context.SENSOR_SERVICE) as SensorManager) 260 } 261 262 /** 263 * If this is the first time when we're going to show the dev menu, it creates a new react root view 264 * that will render the other endpoint of home app whose name is described by [DEV_MENU_JS_MODULE_NAME] constant. 265 * Also sets initialProps, layout settings and initial animation values. 266 */ 267 @Throws(Exception::class) 268 private fun prepareRootView(initialProps: Bundle): ReactRootView { 269 // Throw an exception in case the kernel is not initialized yet. 270 if (kernel?.reactInstanceManager == null) { 271 throw Exception("Kernel's React instance manager is not initialized yet.") 272 } 273 274 if (reactRootView == null) { 275 reactRootView = ReactUnthemedRootView(kernel.activityContext) 276 reactRootView?.startReactApplication(kernel.reactInstanceManager, DEV_MENU_JS_MODULE_NAME, initialProps) 277 } else { 278 reactRootView?.appProperties = initialProps 279 } 280 281 val rootView = reactRootView!! 282 283 rootView.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) 284 rootView.visibility = View.VISIBLE 285 286 return rootView 287 } 288 289 /** 290 * Returns an instance implementing [DevMenuModuleInterface] linked to the current [ExperienceActivity], or null if the current 291 * activity is not of [ExperienceActivity] type or there is no module registered for that activity. 292 */ 293 private fun getCurrentDevMenuModule(): DevMenuModuleInterface? { 294 val currentActivity = getCurrentExperienceActivity() 295 return if (currentActivity != null) devMenuModulesRegistry[currentActivity] else null 296 } 297 298 /** 299 * Returns current activity if it's of type [ExperienceActivity], or null otherwise. 300 */ 301 private fun getCurrentExperienceActivity(): ExperienceActivity? { 302 return ExperienceActivity.getCurrentActivity() 303 } 304 305 /** 306 * Checks whether the dev menu is visible anywhere. 307 */ 308 private fun isDevMenuVisible(): Boolean { 309 return reactRootView?.parent != null 310 } 311 312 /** 313 * Handles shake gesture which simply toggles the dev menu. 314 */ 315 private fun onShakeGesture() { 316 val currentActivity = ExperienceActivity.getCurrentActivity() 317 318 if (currentActivity != null) { 319 toggleInActivity(currentActivity) 320 } 321 } 322 323 private fun tryToPauseHostActivity(activity: ExperienceActivity) { 324 try { 325 kernel?.reactInstanceManager?.onHostPause(activity) 326 } catch (e: AssertionError) { 327 // nothing 328 } 329 } 330 331 //endregion internals 332 } 333