package expo.modules.devlauncher import android.app.Application import android.content.Context import android.content.Intent import android.net.Uri import androidx.annotation.UiThread import com.facebook.react.ReactActivity import com.facebook.react.ReactActivityDelegate import com.facebook.react.ReactApplication import com.facebook.react.ReactPackage import com.facebook.react.bridge.ReactContext import expo.interfaces.devmenu.ReactHostWrapper import expo.modules.devlauncher.helpers.DevLauncherInstallationIDHelper import expo.modules.devlauncher.helpers.DevLauncherMetadataHelper import expo.modules.devlauncher.helpers.DevLauncherUrl import expo.modules.devlauncher.helpers.getFieldInClassHierarchy import expo.modules.devlauncher.helpers.hasUrlQueryParam import expo.modules.devlauncher.helpers.isDevLauncherUrl import expo.modules.devlauncher.helpers.runBlockingOnMainThread import expo.modules.devlauncher.koin.DevLauncherKoinComponent import expo.modules.devlauncher.koin.DevLauncherKoinContext import expo.modules.devlauncher.koin.devLauncherKoin import expo.modules.devlauncher.koin.optInject import expo.modules.devlauncher.launcher.DevLauncherActivity import expo.modules.devlauncher.launcher.DevLauncherAppEntry import expo.modules.devlauncher.launcher.DevLauncherControllerInterface import expo.modules.devlauncher.launcher.DevLauncherIntentRegistryInterface import expo.modules.devlauncher.launcher.DevLauncherLifecycle import expo.modules.devlauncher.launcher.DevLauncherNetworkInterceptor import expo.modules.devlauncher.launcher.DevLauncherReactActivityDelegateSupplier import expo.modules.devlauncher.launcher.DevLauncherReactHost import expo.modules.devlauncher.launcher.DevLauncherReactNativeHost import expo.modules.devlauncher.launcher.DevLauncherRecentlyOpenedAppsRegistry import expo.modules.devlauncher.launcher.errors.DevLauncherAppError import expo.modules.devlauncher.launcher.errors.DevLauncherErrorActivity import expo.modules.devlauncher.launcher.errors.DevLauncherUncaughtExceptionHandler import expo.modules.devlauncher.launcher.loaders.DevLauncherAppLoaderFactoryInterface import expo.modules.devlauncher.launcher.manifest.DevLauncherManifestParser import expo.modules.devlauncher.react.activitydelegates.DevLauncherReactActivityNOPDelegate import expo.modules.devlauncher.react.activitydelegates.DevLauncherReactActivityRedirectDelegate import expo.modules.devlauncher.tests.DevLauncherTestInterceptor import expo.modules.devmenu.DevMenuManager import expo.modules.manifests.core.Manifest import expo.modules.updatesinterface.UpdatesInterface import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import okhttp3.OkHttpClient import org.koin.core.component.get import org.koin.core.component.inject import org.koin.dsl.module // Use this to load from a development server for the development client launcher UI // private val DEV_LAUNCHER_HOST = "10.0.0.175:8090"; private val DEV_LAUNCHER_HOST: String? = null private const val NEW_ACTIVITY_FLAGS = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NO_ANIMATION class DevLauncherController private constructor() : DevLauncherKoinComponent, DevLauncherControllerInterface { private val context: Context by lazy { DevLauncherKoinContext.app.koin.get() } override val appHost: ReactHostWrapper by inject() private val httpClient: OkHttpClient by inject() private val lifecycle: DevLauncherLifecycle by inject() private val pendingIntentRegistry: DevLauncherIntentRegistryInterface by inject() private val installationIDHelper: DevLauncherInstallationIDHelper by inject() val internalUpdatesInterface: UpdatesInterface? by optInject() var devMenuManager: DevMenuManager = DevMenuManager override var updatesInterface: UpdatesInterface? get() = internalUpdatesInterface set(value) = DevLauncherKoinContext.app.koin.loadModules( listOf( module { single { value } } ) ) override val coroutineScope = CoroutineScope(Dispatchers.Default) override val devClientHost by lazy { ReactHostWrapper( reactNativeHost = DevLauncherReactNativeHost(context as Application, DEV_LAUNCHER_HOST), reactHostProvider = { DevLauncherReactHost.create(context as Application, DEV_LAUNCHER_HOST) } ) } private val recentlyOpedAppsRegistry = DevLauncherRecentlyOpenedAppsRegistry(context) override var manifest: Manifest? = null private set override var manifestURL: Uri? = null private set override var latestLoadedApp: Uri? = null override var useDeveloperSupport = true var canLaunchDevMenuOnStart = false enum class Mode { LAUNCHER, APP } override var mode = Mode.LAUNCHER private var appIsLoading = false private var networkInterceptor: DevLauncherNetworkInterceptor? = null private fun isEASUpdateURL(url: Uri): Boolean { return url.host.equals("u.expo.dev") || url.host.equals("staging-u.expo.dev") } override fun onRequestRelaunch() { val latestLoadedApp = latestLoadedApp ?: return coroutineScope.launch { loadApp( latestLoadedApp, appHost.currentReactContext?.currentActivity as? ReactActivity? ) } } override suspend fun loadApp(url: Uri, projectUrl: Uri?, mainActivity: ReactActivity?) { synchronized(this) { if (appIsLoading) { return } appIsLoading = true } try { ensureHostWasCleared(appHost, activityToBeInvalidated = mainActivity) val devLauncherUrl = DevLauncherUrl(url) val parsedUrl = devLauncherUrl.url var parsedProjectUrl = projectUrl ?: url val isEASUpdate = isEASUpdateURL(url) // default to the EXPO_UPDATE_URL value configured in AndroidManifest.xml when project url is unspecified for an EAS update if (isEASUpdate && projectUrl == null) { val projectUrlString = getMetadataValue(context, "expo.modules.updates.EXPO_UPDATE_URL") parsedProjectUrl = Uri.parse(projectUrlString) } val manifestParser = DevLauncherManifestParser(httpClient, parsedUrl, installationIDHelper.getOrCreateInstallationID(context)) val appIntent = createAppIntent() DevLauncherKoinContext.app.koin.getOrNull()?.reset() val appLoaderFactory = get() val appLoader = appLoaderFactory.createAppLoader(parsedUrl, parsedProjectUrl, manifestParser) useDeveloperSupport = appLoaderFactory.shouldUseDeveloperSupport() manifest = appLoaderFactory.getManifest() manifestURL = parsedUrl setupDevMenu(url.toString()) val appLoaderListener = appLoader.createOnDelegateWillBeCreatedListener() lifecycle.addListener(appLoaderListener) mode = Mode.APP // Note that `launch` method is a suspend one. So the execution will be stopped here until the method doesn't finish. if (appLoader.launch(appIntent)) { recentlyOpedAppsRegistry.appWasOpened(parsedUrl.toString(), devLauncherUrl.queryParams, manifest) latestLoadedApp = parsedUrl // Here the app will be loaded - we can remove listener here. lifecycle.removeListener(appLoaderListener) } else { // The app couldn't be loaded. For now, we just return to the launcher. mode = Mode.LAUNCHER manifest = null manifestURL = null invalidateDevMenu() } } catch (e: Exception) { synchronized(this) { appIsLoading = false } throw e } } override suspend fun loadApp(url: Uri, mainActivity: ReactActivity?) { loadApp(url, null, mainActivity) } override fun onAppLoaded(context: ReactContext) { synchronized(this) { appIsLoading = false } manifestURL?.let { runBlockingOnMainThread { networkInterceptor = DevLauncherNetworkInterceptor(it) } } } override fun onAppLoadedWithError() { synchronized(this) { appIsLoading = false } } override fun getRecentlyOpenedApps(): List = recentlyOpedAppsRegistry.getRecentlyOpenedApps() override fun clearRecentlyOpenedApps() { recentlyOpedAppsRegistry.clearRegistry() } override fun navigateToLauncher() { ensureHostWasCleared(appHost) synchronized(this) { appIsLoading = false } mode = Mode.LAUNCHER manifest = null manifestURL = null invalidateDevMenu() context.applicationContext.startActivity(createLauncherIntent()) } override fun handleIntent(intent: Intent?, activityToBeInvalidated: ReactActivity?): Boolean { intent ?.data ?.let { uri -> // used by appetize for snack if (intent.getBooleanExtra("EXDevMenuDisableAutoLaunch", false)) { canLaunchDevMenuOnStart = false this.devMenuManager.setCanLaunchDevMenuOnStart(canLaunchDevMenuOnStart) } if (!isDevLauncherUrl(uri)) { return handleExternalIntent(intent) } if (!hasUrlQueryParam(uri)) { // edge case: this is a dev launcher url but it does not specify what url to open // fallback to navigating to the launcher home screen navigateToLauncher() return true } coroutineScope.launch { try { loadApp(uri, activityToBeInvalidated) } catch (e: Throwable) { DevLauncherErrorActivity.showFatalError(context, DevLauncherAppError(e.message, e)) } } return true } intent?.let { // If the app is already open or the intent is not a main intent, we don't want to handle it. if (mode == Mode.APP || intent.action != Intent.ACTION_MAIN) { return@let } val shouldTryToLaunchLastOpenedBundle = getMetadataValue(context, "DEV_CLIENT_TRY_TO_LAUNCH_LAST_BUNDLE", "true").toBoolean() val lastOpenedApp = recentlyOpedAppsRegistry.getMostRecentApp() if (shouldTryToLaunchLastOpenedBundle && lastOpenedApp != null) { coroutineScope.launch { try { loadApp(Uri.parse(lastOpenedApp.url), activityToBeInvalidated) } catch (e: Throwable) { navigateToLauncher() } } return true } return handleExternalIntent(it) } return false } private fun handleExternalIntent(intent: Intent): Boolean { if (mode != Mode.APP && intent.action != Intent.ACTION_MAIN) { pendingIntentRegistry.intent = intent } return false } private fun ensureHostWasCleared(host: ReactHostWrapper, activityToBeInvalidated: ReactActivity? = null) { if (host.hasInstance) { runBlockingOnMainThread { networkInterceptor?.close() networkInterceptor = null clearHost(host, activityToBeInvalidated) } } } private fun setupDevMenu(launchUrl: String) { devMenuManager.currentManifest = manifest devMenuManager.currentManifestURL = manifestURL.toString() devMenuManager.launchUrl = launchUrl } private fun invalidateDevMenu() { devMenuManager.currentManifest = null devMenuManager.currentManifestURL = null devMenuManager.launchUrl = null } @UiThread private fun clearHost(host: ReactHostWrapper, activityToBeInvalidated: ReactActivity?) { host.destroy() activityToBeInvalidated?.let { invalidateActivity(it) } } override fun getCurrentReactActivityDelegate(activity: ReactActivity, delegateSupplierDevLauncher: DevLauncherReactActivityDelegateSupplier): ReactActivityDelegate { return if (mode == Mode.LAUNCHER) { DevLauncherReactActivityRedirectDelegate(activity, this::redirectFromStartActivity) } else { delegateSupplierDevLauncher.get() } } private fun redirectFromStartActivity(intent: Intent?) { if (!handleIntent(intent, null)) { navigateToLauncher() } } /** * If we try to launch a different app when the `MainActivity` is active, the app will crash * (NPE caused by missing activity reference in the [ReactActivityDelegate]). * To prevent such behavior, we need to invalidate active activity. To do it we switch * the inner [ReactActivityDelegate] to be a NOP object. */ private fun invalidateActivity(activity: ReactActivity) { val field = activity::class.java.getFieldInClassHierarchy("mDelegate") requireNotNull(field) { "Cannot find mDelegate field in activity." } field.isAccessible = true field.set(activity, DevLauncherReactActivityNOPDelegate(activity)) } private fun createLauncherIntent() = Intent(context, DevLauncherActivity::class.java) .apply { addFlags(NEW_ACTIVITY_FLAGS) } private fun createAppIntent() = createBasicAppIntent().apply { pendingIntentRegistry .consumePendingIntent() ?.let { intent -> action = intent.action data = intent.data intent.extras?.let { putExtras(it) } intent.categories?.let { categories.addAll(it) } } } private fun createBasicAppIntent() = if (sLauncherClass == null) { checkNotNull( context .packageManager .getLaunchIntentForPackage(context.packageName) ) { "Couldn't find the launcher class." } } else { Intent(context, sLauncherClass!!) }.apply { addFlags(NEW_ACTIVITY_FLAGS) } companion object { private var sErrorHandlerWasInitialized = false private var sLauncherClass: Class<*>? = null internal var sAdditionalPackages: List? = null @JvmStatic fun getMetadataValue(context: Context, key: String, defaultValue: String = "") = DevLauncherMetadataHelper.getMetadataValue(context, key, defaultValue) @JvmStatic fun wasInitialized() = DevLauncherKoinContext.app.koin.getOrNull() != null @JvmStatic val instance: DevLauncherControllerInterface get() = checkNotNull( DevLauncherKoinContext.app.koin.getOrNull() ) { "DevelopmentClientController.getInstance() was called before the module was initialized" } @JvmStatic internal fun initialize(context: Context, reactHost: ReactHostWrapper) { val testInterceptor = DevLauncherKoinContext.app.koin.get() if (!testInterceptor.allowReinitialization()) { check(!wasInitialized()) { "DevelopmentClientController was initialized." } } DevLauncherKoinContext.app.koin.loadModules( listOf( module { single { context } single { reactHost } } ), allowOverride = true ) val controller = DevLauncherController() DevLauncherKoinContext.app.koin.declare(controller) if (!sErrorHandlerWasInitialized && context is Application) { val handler = DevLauncherUncaughtExceptionHandler( controller, context, Thread.getDefaultUncaughtExceptionHandler() ) Thread.setDefaultUncaughtExceptionHandler(handler) sErrorHandlerWasInitialized = true } } @JvmStatic fun initialize(context: Context, reactHost: ReactHostWrapper, launcherClass: Class<*>? = null) { initialize(context, reactHost) sLauncherClass = launcherClass } @JvmStatic fun initialize(reactApplication: ReactApplication, additionalPackages: List? = null, launcherClass: Class<*>? = null) { initialize(reactApplication as Context, ReactHostWrapper(reactApplication.reactNativeHost, { reactApplication.reactHost })) sAdditionalPackages = additionalPackages sLauncherClass = launcherClass } @JvmStatic fun wrapReactActivityDelegate(activity: ReactActivity, devLauncherReactActivityDelegateSupplier: DevLauncherReactActivityDelegateSupplier): ReactActivityDelegate { devLauncherKoin() .get() .delegateWillBeCreated(activity) return devLauncherKoin() .get() .getCurrentReactActivityDelegate(activity, devLauncherReactActivityDelegateSupplier) } @JvmStatic fun tryToHandleIntent(activity: ReactActivity, intent: Intent): Boolean { return devLauncherKoin() .get() .handleIntent(intent, activityToBeInvalidated = activity) } } }