1 package expo.modules.devlauncher.modules 2 3 import android.content.ActivityNotFoundException 4 import android.content.ClipData 5 import android.content.ClipboardManager 6 import android.content.Context 7 import android.content.Intent 8 import android.content.pm.PackageManager 9 import android.net.Uri 10 import com.facebook.react.bridge.* 11 import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter 12 import expo.modules.core.utilities.EmulatorUtilities 13 import expo.modules.devlauncher.DevLauncherController 14 import expo.modules.devlauncher.DevLauncherController.Companion.wasInitialized 15 import expo.modules.devlauncher.helpers.DevLauncherInstallationIDHelper 16 import expo.modules.devlauncher.koin.DevLauncherKoinComponent 17 import expo.modules.devlauncher.launcher.DevLauncherControllerInterface 18 import expo.modules.devlauncher.launcher.DevLauncherIntentRegistryInterface 19 import expo.modules.devlauncher.launcher.errors.DevLauncherErrorRegistry 20 import expo.modules.devmenu.DevMenuManager 21 import kotlinx.coroutines.launch 22 import org.koin.core.component.inject 23 24 private const val ON_NEW_DEEP_LINK_EVENT = "expo.modules.devlauncher.onnewdeeplink" 25 private const val CLIENT_PACKAGE_NAME = "host.exp.exponent" 26 private val CLIENT_HOME_QR_SCANNER_DEEP_LINK = Uri.parse("expo-home://qr-scanner") 27 private const val LAUNCHER_NAVIGATION_STATE_KEY = "expo.modules.devlauncher.navigation-state" 28 29 class DevLauncherInternalModule(reactContext: ReactApplicationContext?) : 30 ReactContextBaseJavaModule(reactContext), DevLauncherKoinComponent { 31 private val controller: DevLauncherControllerInterface by inject() 32 private val intentRegistry: DevLauncherIntentRegistryInterface by inject() 33 private val installationIDHelper: DevLauncherInstallationIDHelper by inject() 34 initializenull35 override fun initialize() { 36 super.initialize() 37 if (wasInitialized()) { 38 intentRegistry.subscribe(this::onNewPendingIntent) 39 } 40 } 41 invalidatenull42 override fun invalidate() { 43 super.invalidate() 44 if (wasInitialized()) { 45 intentRegistry.unsubscribe(this::onNewPendingIntent) 46 } 47 } 48 getNamenull49 override fun getName() = "EXDevLauncherInternal" 50 51 override fun getConstants(): Map<String, Any> { 52 val isRunningOnEmulator = EmulatorUtilities.isRunningOnEmulator() 53 return mapOf( 54 "installationID" to installationIDHelper.getOrCreateInstallationID(reactApplicationContext), 55 "isDevice" to !isRunningOnEmulator, 56 "updatesConfig" to getUpdatesConfig(), 57 ) 58 } 59 getUpdatesConfignull60 private fun getUpdatesConfig(): WritableMap { 61 val map = Arguments.createMap() 62 63 val runtimeVersion = DevLauncherController.getMetadataValue(reactApplicationContext, "expo.modules.updates.EXPO_RUNTIME_VERSION") 64 val sdkVersion = DevLauncherController.getMetadataValue(reactApplicationContext, "expo.modules.updates.EXPO_SDK_VERSION") 65 var projectUrl = DevLauncherController.getMetadataValue(reactApplicationContext, "expo.modules.updates.EXPO_UPDATE_URL") 66 67 val appId = if (projectUrl.isNotEmpty()) { 68 Uri.parse(projectUrl).lastPathSegment ?: "" 69 } else { 70 "" 71 } 72 73 val projectUri = Uri.parse(projectUrl) 74 75 val isModernManifestProtocol = projectUri.host.equals("u.expo.dev") || projectUri.host.equals("staging-u.expo.dev") 76 val usesEASUpdates = isModernManifestProtocol && appId.isNotEmpty() 77 78 return map.apply { 79 putString("appId", appId) 80 putString("runtimeVersion", runtimeVersion) 81 putString("sdkVersion", sdkVersion) 82 putBoolean("usesEASUpdates", usesEASUpdates) 83 putString("projectUrl", projectUrl) 84 } 85 } 86 sanitizeUrlStringnull87 private fun sanitizeUrlString(url: String): Uri { 88 var sanitizedUrl = url.trim() 89 // If the url does contain a scheme use "http://" 90 if(!sanitizedUrl.contains("://")) { 91 sanitizedUrl = "http://" + sanitizedUrl 92 } 93 94 return Uri.parse(sanitizedUrl) 95 } 96 97 @ReactMethod loadUpdatenull98 fun loadUpdate(url: String, projectUrlString: String?, promise: Promise) { 99 controller.coroutineScope.launch { 100 try { 101 val appUrl = sanitizeUrlString(url) 102 var projectUrl: Uri? = null 103 if (projectUrlString != null) { 104 projectUrl = sanitizeUrlString(projectUrlString) 105 } 106 controller.loadApp(appUrl, projectUrl) 107 } catch (e: Exception) { 108 promise.reject("ERR_DEV_LAUNCHER_CANNOT_LOAD_APP", e.message, e) 109 return@launch 110 } 111 promise.resolve(null) 112 } 113 } 114 115 @ReactMethod loadAppnull116 fun loadApp(url: String, promise: Promise) { 117 controller.coroutineScope.launch { 118 try { 119 val parsedUrl = sanitizeUrlString(url) 120 controller.loadApp(parsedUrl) 121 } catch (e: Exception) { 122 promise.reject("ERR_DEV_LAUNCHER_CANNOT_LOAD_APP", e.message, e) 123 return@launch 124 } 125 promise.resolve(null) 126 } 127 } 128 129 @ReactMethod getRecentlyOpenedAppsnull130 fun getRecentlyOpenedApps(promise: Promise) { 131 val apps = Arguments.createArray() 132 133 for (recentlyOpenedApp in controller.getRecentlyOpenedApps()) { 134 val app = Arguments.createMap() 135 136 app.putDouble("timestamp", recentlyOpenedApp.timestamp.toDouble()) 137 app.putString("name", recentlyOpenedApp.name) 138 app.putString("url", recentlyOpenedApp.url) 139 app.putBoolean("isEASUpdate", recentlyOpenedApp.isEASUpdate == true) 140 141 if (recentlyOpenedApp.isEASUpdate == true) { 142 app.putString("updateMessage", recentlyOpenedApp.updateMessage) 143 app.putString("branchName", recentlyOpenedApp.branchName) 144 } 145 146 apps.pushMap(app) 147 } 148 149 return promise.resolve(apps) 150 } 151 152 @ReactMethod clearRecentlyOpenedAppsnull153 fun clearRecentlyOpenedApps(promise: Promise) { 154 controller.clearRecentlyOpenedApps() 155 return promise.resolve(null) 156 } 157 158 @ReactMethod openCameranull159 fun openCamera(promise: Promise) { 160 val packageManager = reactApplicationContext.packageManager 161 162 packageManager.getLaunchIntentForPackage(CLIENT_PACKAGE_NAME) 163 ?.let { 164 tryToDeepLinkIntoQRScannerDirectly(it) 165 promise.resolve(null) 166 return 167 } 168 169 // try to open the Play Store app... 170 if (openLink(Uri.parse("market://details?id=$CLIENT_PACKAGE_NAME"))) { 171 return 172 } 173 174 // ...app isn't installed so fallback to the Play Store website 175 if (openLink(Uri.parse("https://play.google.com/store/apps/details?id=$CLIENT_PACKAGE_NAME"))) { 176 return 177 } 178 179 promise.reject("ERR_DEVELOPMENT_CLIENT_CANNOT_OPEN_CAMERA", "Couldn't find the Expo Go app.") 180 } 181 182 @ReactMethod getPendingDeepLinknull183 fun getPendingDeepLink(promise: Promise) { 184 intentRegistry.intent?.data?.let { 185 promise.resolve(it.toString()) 186 return 187 } 188 189 promise.resolve(intentRegistry.intent?.action) 190 } 191 192 @ReactMethod getCrashReportnull193 fun getCrashReport(promise: Promise) { 194 val registry = DevLauncherErrorRegistry(reactApplicationContext) 195 promise.resolve(registry.consumeException()?.toWritableMap()) 196 } 197 onNewPendingIntentnull198 private fun onNewPendingIntent(intent: Intent) { 199 intent.data?.toString()?.let { 200 reactApplicationContext 201 .getJSModule(RCTDeviceEventEmitter::class.java) 202 .emit(ON_NEW_DEEP_LINK_EVENT, it) 203 } 204 } 205 tryToDeepLinkIntoQRScannerDirectlynull206 private fun tryToDeepLinkIntoQRScannerDirectly(fallback: Intent) { 207 if (openLink(CLIENT_HOME_QR_SCANNER_DEEP_LINK)) { 208 return 209 } 210 211 reactApplicationContext.startActivity(fallback) 212 } 213 openLinknull214 private fun openLink(uri: Uri): Boolean { 215 return try { 216 reactApplicationContext.startActivity( 217 Intent(Intent.ACTION_VIEW, uri).apply { 218 addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 219 } 220 ) 221 true 222 } catch (_: ActivityNotFoundException) { 223 false 224 } 225 } 226 227 @ReactMethod getBuildInfonull228 fun getBuildInfo(promise: Promise) { 229 val map = Arguments.createMap() 230 val packageManager = reactApplicationContext.packageManager 231 val packageName = reactApplicationContext.packageName 232 233 val packageInfo = packageManager.getPackageInfo(packageName, 0) 234 val applicationInfo = packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA) 235 val appName = packageManager.getApplicationLabel(applicationInfo).toString() 236 val runtimeVersion = DevLauncherController.getMetadataValue(reactApplicationContext, "expo.modules.updates.EXPO_RUNTIME_VERSION") 237 val sdkVersion = DevLauncherController.getMetadataValue(reactApplicationContext, "expo.modules.updates.EXPO_SDK_VERSION") 238 var appIcon = getApplicationIconUri() 239 240 var updatesUrl = DevLauncherController.getMetadataValue(reactApplicationContext, "expo.modules.updates.EXPO_UPDATE_URL") 241 var appId = "" 242 243 if (updatesUrl.isNotEmpty()) { 244 var uri = Uri.parse(updatesUrl) 245 appId = uri.lastPathSegment ?: "" 246 } 247 248 map.apply { 249 putString("appVersion", packageInfo.versionName) 250 putString("appId", appId) 251 putString("appName", appName) 252 putString("appIcon", appIcon) 253 putString("runtimeVersion", runtimeVersion) 254 putString("sdkVersion", sdkVersion) 255 } 256 257 promise.resolve(map) 258 } 259 260 @ReactMethod copyToClipboardnull261 fun copyToClipboard(content: String, promise: Promise) { 262 val clipboard = reactApplicationContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager 263 val clip = ClipData.newPlainText(null, content) 264 clipboard.setPrimaryClip(clip) 265 promise.resolve(null) 266 } 267 getApplicationIconUrinull268 private fun getApplicationIconUri(): String { 269 val packageManager = reactApplicationContext.packageManager 270 val packageName = reactApplicationContext.packageName 271 val applicationInfo = packageManager.getApplicationInfo(packageName, 0) 272 // TODO - figure out how to get resId for AdaptiveIconDrawable icons 273 return "" + applicationInfo.icon 274 } 275 276 @ReactMethod loadFontsAsyncnull277 fun loadFontsAsync(promise: Promise) { 278 DevMenuManager.loadFonts(reactApplicationContext) 279 promise.resolve(null) 280 } 281 282 @ReactMethod getNavigationStatenull283 fun getNavigationState(promise: Promise) { 284 val sharedPreferences = reactApplicationContext.getSharedPreferences(LAUNCHER_NAVIGATION_STATE_KEY, Context.MODE_PRIVATE) 285 val serializedNavigationState = sharedPreferences.getString("navigationState", null) ?: "" 286 promise.resolve(serializedNavigationState) 287 } 288 289 @ReactMethod saveNavigationStatenull290 fun saveNavigationState(serializedNavigationState: String, promise: Promise) { 291 val sharedPreferences = reactApplicationContext.getSharedPreferences(LAUNCHER_NAVIGATION_STATE_KEY, Context.MODE_PRIVATE) 292 sharedPreferences.edit().putString("navigationState", serializedNavigationState).apply() 293 promise.resolve(null) 294 } 295 296 @ReactMethod clearNavigationStatenull297 fun clearNavigationState(promise: Promise) { 298 val sharedPreferences = reactApplicationContext.getSharedPreferences(LAUNCHER_NAVIGATION_STATE_KEY, Context.MODE_PRIVATE) 299 sharedPreferences.edit().clear().apply() 300 promise.resolve(null) 301 } 302 } 303