1 // Copyright 2015-present 650 Industries. All rights reserved. 2 package host.exp.exponent 3 4 import android.content.Context 5 import android.net.Uri 6 import android.util.Log 7 import expo.modules.jsonutils.getNullable 8 import expo.modules.manifests.core.LegacyManifest 9 import expo.modules.core.utilities.EmulatorUtilities 10 import expo.modules.updates.UpdatesConfiguration 11 import expo.modules.updates.UpdatesUtils 12 import expo.modules.updates.db.DatabaseHolder 13 import expo.modules.updates.db.entity.UpdateEntity 14 import expo.modules.updates.launcher.Launcher 15 import expo.modules.updates.launcher.NoDatabaseLauncher 16 import expo.modules.updates.loader.FileDownloader 17 import expo.modules.updates.loader.LoaderTask 18 import expo.modules.updates.loader.LoaderTask.LoaderTaskCallback 19 import expo.modules.updates.loader.LoaderTask.RemoteUpdateStatus 20 import expo.modules.updates.manifest.UpdateManifest 21 import expo.modules.manifests.core.Manifest 22 import expo.modules.updates.codesigning.CODE_SIGNING_METADATA_ALGORITHM_KEY 23 import expo.modules.updates.codesigning.CODE_SIGNING_METADATA_KEY_ID_KEY 24 import expo.modules.updates.codesigning.CodeSigningAlgorithm 25 import expo.modules.updates.manifest.EmbeddedManifest 26 import expo.modules.updates.selectionpolicy.LoaderSelectionPolicyFilterAware 27 import expo.modules.updates.selectionpolicy.ReaperSelectionPolicyDevelopmentClient 28 import expo.modules.updates.selectionpolicy.SelectionPolicy 29 import host.exp.exponent.di.NativeModuleDepsProvider 30 import host.exp.exponent.exceptions.ManifestException 31 import host.exp.exponent.kernel.ExperienceKey 32 import host.exp.exponent.kernel.ExpoViewKernel 33 import host.exp.exponent.kernel.Kernel 34 import host.exp.exponent.kernel.KernelConfig 35 import host.exp.exponent.storage.ExponentSharedPreferences 36 import org.json.JSONArray 37 import org.json.JSONException 38 import org.json.JSONObject 39 import java.io.File 40 import java.util.* 41 import javax.inject.Inject 42 43 private const val UPDATE_AVAILABLE_EVENT = "updateAvailable" 44 private const val UPDATE_NO_UPDATE_AVAILABLE_EVENT = "noUpdateAvailable" 45 private const val UPDATE_ERROR_EVENT = "error" 46 47 /** 48 * Entry point to expo-updates in Expo Go and legacy standalone builds. Fulfills many of the 49 * purposes of [UpdatesController] along with serving as an interface to the rest of the ExpoKit 50 * kernel. 51 * 52 * Dynamically generates a configuration object with the correct scope key, and then, like 53 * [UpdatesController], delegates to an instance of [LoaderTask] to start the process of loading and 54 * launching an update, and responds appropriately depending on the callbacks that are invoked. 55 * 56 * Multiple instances of ExpoUpdatesAppLoader can exist at a time; instances are retained by 57 * [Kernel]. 58 */ 59 class ExpoUpdatesAppLoader @JvmOverloads constructor( 60 private val manifestUrl: String, 61 private val callback: AppLoaderCallback, 62 private val useCacheOnly: Boolean = false 63 ) { 64 @Inject 65 lateinit var exponentManifest: ExponentManifest 66 67 @Inject 68 lateinit var exponentSharedPreferences: ExponentSharedPreferences 69 70 @Inject 71 lateinit var databaseHolder: DatabaseHolder 72 73 @Inject 74 lateinit var kernel: Kernel 75 76 enum class AppLoaderStatus { 77 CHECKING_FOR_UPDATE, DOWNLOADING_NEW_UPDATE 78 } 79 80 var isEmergencyLaunch = false 81 private set 82 var isUpToDate = true 83 private set 84 var status: AppLoaderStatus? = null 85 private set 86 var shouldShowAppLoaderStatus = true 87 private set 88 private var isStarted = false 89 90 interface AppLoaderCallback { onOptimisticManifestnull91 fun onOptimisticManifest(optimisticManifest: Manifest) 92 fun onManifestCompleted(manifest: Manifest) 93 fun onBundleCompleted(localBundlePath: String) 94 fun emitEvent(params: JSONObject) 95 fun updateStatus(status: AppLoaderStatus) 96 fun onError(e: Exception) 97 } 98 99 lateinit var updatesConfiguration: UpdatesConfiguration 100 private set 101 102 lateinit var updatesDirectory: File 103 private set 104 105 lateinit var selectionPolicy: SelectionPolicy 106 private set 107 108 lateinit var fileDownloader: FileDownloader 109 private set 110 111 lateinit var launcher: Launcher 112 private set 113 114 private fun updateStatus(status: AppLoaderStatus) { 115 this.status = status 116 callback.updateStatus(status) 117 } 118 startnull119 fun start(context: Context) { 120 check(!isStarted) { "AppLoader for $manifestUrl was started twice. AppLoader.start() may only be called once per instance." } 121 isStarted = true 122 status = AppLoaderStatus.CHECKING_FOR_UPDATE 123 fileDownloader = FileDownloader(context) 124 kernel.addAppLoaderForManifestUrl(manifestUrl, this) 125 val httpManifestUrl = exponentManifest.httpManifestUrl(manifestUrl) 126 var releaseChannel = Constants.RELEASE_CHANNEL 127 if (!Constants.isStandaloneApp()) { 128 // in Expo Go, the release channel can change at runtime depending on the URL we load 129 val releaseChannelQueryParam = 130 httpManifestUrl.getQueryParameter(ExponentManifest.QUERY_PARAM_KEY_RELEASE_CHANNEL) 131 if (releaseChannelQueryParam != null) { 132 releaseChannel = releaseChannelQueryParam 133 } 134 } 135 val configMap = mutableMapOf<String, Any>() 136 configMap[UpdatesConfiguration.UPDATES_CONFIGURATION_UPDATE_URL_KEY] = httpManifestUrl 137 configMap[UpdatesConfiguration.UPDATES_CONFIGURATION_SCOPE_KEY_KEY] = httpManifestUrl.toString() 138 configMap[UpdatesConfiguration.UPDATES_CONFIGURATION_SDK_VERSION_KEY] = Constants.SDK_VERSIONS 139 configMap[UpdatesConfiguration.UPDATES_CONFIGURATION_RELEASE_CHANNEL_KEY] = releaseChannel 140 configMap[UpdatesConfiguration.UPDATES_CONFIGURATION_HAS_EMBEDDED_UPDATE_KEY] = Constants.isStandaloneApp() 141 configMap[UpdatesConfiguration.UPDATES_CONFIGURATION_ENABLED_KEY] = Constants.ARE_REMOTE_UPDATES_ENABLED 142 if (useCacheOnly) { 143 configMap[UpdatesConfiguration.UPDATES_CONFIGURATION_CHECK_ON_LAUNCH_KEY] = "NEVER" 144 configMap[UpdatesConfiguration.UPDATES_CONFIGURATION_LAUNCH_WAIT_MS_KEY] = 0 145 } else { 146 if (Constants.isStandaloneApp()) { 147 configMap[UpdatesConfiguration.UPDATES_CONFIGURATION_CHECK_ON_LAUNCH_KEY] = if (Constants.UPDATES_CHECK_AUTOMATICALLY) "ALWAYS" else "NEVER" 148 configMap[UpdatesConfiguration.UPDATES_CONFIGURATION_LAUNCH_WAIT_MS_KEY] = Constants.UPDATES_FALLBACK_TO_CACHE_TIMEOUT 149 } else { 150 configMap[UpdatesConfiguration.UPDATES_CONFIGURATION_CHECK_ON_LAUNCH_KEY] = "ALWAYS" 151 configMap[UpdatesConfiguration.UPDATES_CONFIGURATION_LAUNCH_WAIT_MS_KEY] = 60000 152 } 153 } 154 configMap[UpdatesConfiguration.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY] = requestHeaders 155 configMap[UpdatesConfiguration.UPDATES_CONFIGURATION_EXPECTS_EXPO_SIGNED_MANIFEST] = true 156 if (!Constants.isStandaloneApp()) { 157 // in Expo Go, embed the Expo Root Certificate and get the Expo Go intermediate certificate and development certificates from the multipart manifest response part 158 configMap[UpdatesConfiguration.UPDATES_CONFIGURATION_CODE_SIGNING_CERTIFICATE] = context.assets.open("expo-root.pem").readBytes().decodeToString() 159 configMap[UpdatesConfiguration.UPDATES_CONFIGURATION_CODE_SIGNING_METADATA] = mapOf( 160 CODE_SIGNING_METADATA_KEY_ID_KEY to "expo-root", 161 CODE_SIGNING_METADATA_ALGORITHM_KEY to CodeSigningAlgorithm.RSA_SHA256.algorithmName, 162 ) 163 configMap[UpdatesConfiguration.UPDATES_CONFIGURATION_CODE_SIGNING_INCLUDE_MANIFEST_RESPONSE_CERTIFICATE_CHAIN] = true 164 configMap[UpdatesConfiguration.UPDATES_CONFIGURATION_CODE_SIGNING_ALLOW_UNSIGNED_MANIFESTS] = true 165 // in Expo Go, ignore directives in manifest responses and require a manifest. the current directives (no update available, roll back) don't have any practical use outside of standalone apps 166 configMap[UpdatesConfiguration.UPDATES_CONFIGURATION_ENABLE_EXPO_UPDATES_PROTOCOL_V0_COMPATIBILITY_MODE] = true 167 } 168 169 val configuration = UpdatesConfiguration(null, configMap) 170 val sdkVersionsList = mutableListOf<String>().apply { 171 (Constants.SDK_VERSIONS_LIST + listOf(RNObject.UNVERSIONED)).forEach { 172 add(it) 173 add("exposdk:$it") 174 } 175 } 176 val selectionPolicy = SelectionPolicy( 177 ExpoGoLauncherSelectionPolicyFilterAware(sdkVersionsList), 178 LoaderSelectionPolicyFilterAware(), 179 ReaperSelectionPolicyDevelopmentClient() 180 ) 181 val directory: File = try { 182 UpdatesUtils.getOrCreateUpdatesDirectory(context) 183 } catch (e: Exception) { 184 callback.onError(e) 185 return 186 } 187 startLoaderTask(configuration, directory, selectionPolicy, context) 188 } 189 startLoaderTasknull190 private fun startLoaderTask( 191 configuration: UpdatesConfiguration, 192 directory: File, 193 selectionPolicy: SelectionPolicy, 194 context: Context 195 ) { 196 updatesConfiguration = configuration 197 updatesDirectory = directory 198 this.selectionPolicy = selectionPolicy 199 if (!configuration.isEnabled) { 200 launchWithNoDatabase(context, null) 201 return 202 } 203 LoaderTask( 204 configuration, 205 databaseHolder, 206 directory, 207 fileDownloader, 208 selectionPolicy, 209 object : LoaderTaskCallback { 210 private var didAbort = false 211 override fun onFailure(e: Exception) { 212 if (Constants.isStandaloneApp()) { 213 isEmergencyLaunch = true 214 launchWithNoDatabase(context, e) 215 } else { 216 if (didAbort) { 217 return 218 } 219 var exception = e 220 try { 221 val errorJson = JSONObject(e.message!!) 222 exception = ManifestException(e, manifestUrl, errorJson) 223 } catch (ex: Exception) { 224 // do nothing, expected if the error payload does not come from a conformant server 225 } 226 callback.onError(exception) 227 } 228 } 229 230 override fun onCachedUpdateLoaded(update: UpdateEntity): Boolean { 231 val manifest = Manifest.fromManifestJson(update.manifest) 232 setShouldShowAppLoaderStatus(manifest) 233 if (manifest.isUsingDeveloperTool()) { 234 return false 235 } else { 236 try { 237 val experienceKey = ExperienceKey.fromManifest(manifest) 238 // if previous run of this app failed due to a loading error, we want to make sure to check for remote updates 239 val experienceMetadata = exponentSharedPreferences.getExperienceMetadata(experienceKey) 240 if (experienceMetadata != null && experienceMetadata.optBoolean( 241 ExponentSharedPreferences.EXPERIENCE_METADATA_LOADING_ERROR 242 ) 243 ) { 244 return false 245 } 246 } catch (e: Exception) { 247 return true 248 } 249 } 250 return true 251 } 252 253 override fun onRemoteUpdateManifestResponseManifestLoaded(updateManifest: UpdateManifest) { 254 // expo-cli does not always respect our SDK version headers and respond with a compatible update or an error 255 // so we need to check the compatibility here 256 val sdkVersion = updateManifest.manifest.getExpoGoSDKVersion() 257 if (!isValidSdkVersion(sdkVersion)) { 258 callback.onError(formatExceptionForIncompatibleSdk(sdkVersion)) 259 didAbort = true 260 return 261 } 262 setShouldShowAppLoaderStatus(updateManifest.manifest) 263 callback.onOptimisticManifest(updateManifest.manifest) 264 updateStatus(AppLoaderStatus.DOWNLOADING_NEW_UPDATE) 265 } 266 267 override fun onSuccess(launcher: Launcher, isUpToDate: Boolean) { 268 if (didAbort) { 269 return 270 } 271 this@ExpoUpdatesAppLoader.launcher = launcher 272 this@ExpoUpdatesAppLoader.isUpToDate = isUpToDate 273 try { 274 val manifestJson = processManifestJson(launcher.launchedUpdate!!.manifest) 275 val manifest = Manifest.fromManifestJson(manifestJson) 276 callback.onManifestCompleted(manifest) 277 278 // ReactAndroid will load the bundle on its own in development mode 279 if (!manifest.isDevelopmentMode()) { 280 callback.onBundleCompleted(launcher.launchAssetFile!!) 281 } 282 } catch (e: Exception) { 283 callback.onError(e) 284 } 285 } 286 287 override fun onRemoteUpdateFinished( 288 status: RemoteUpdateStatus, 289 update: UpdateEntity?, 290 exception: Exception? 291 ) { 292 if (didAbort) { 293 return 294 } 295 try { 296 val jsonParams = JSONObject() 297 when (status) { 298 RemoteUpdateStatus.ERROR -> { 299 if (exception == null) { 300 throw AssertionError("Background update with error status must have a nonnull exception object") 301 } 302 jsonParams.put("type", UPDATE_ERROR_EVENT) 303 jsonParams.put("message", exception.message) 304 } 305 RemoteUpdateStatus.UPDATE_AVAILABLE -> { 306 if (update == null) { 307 throw AssertionError("Background update with error status must have a nonnull update object") 308 } 309 jsonParams.put("type", UPDATE_AVAILABLE_EVENT) 310 jsonParams.put("manifestString", update.manifest.toString()) 311 } 312 RemoteUpdateStatus.NO_UPDATE_AVAILABLE -> { 313 jsonParams.put("type", UPDATE_NO_UPDATE_AVAILABLE_EVENT) 314 } 315 } 316 callback.emitEvent(jsonParams) 317 } catch (e: Exception) { 318 Log.e(TAG, "Failed to emit event to JS", e) 319 } 320 } 321 } 322 ).start(context) 323 } 324 launchWithNoDatabasenull325 private fun launchWithNoDatabase(context: Context, e: Exception?) { 326 this.launcher = NoDatabaseLauncher(context, updatesConfiguration, e) 327 var manifestJson = EmbeddedManifest.get(context, updatesConfiguration)!!.manifest.getRawJson() 328 try { 329 manifestJson = processManifestJson(manifestJson) 330 } catch (ex: Exception) { 331 Log.e( 332 TAG, 333 "Failed to process manifest; attempting to launch with raw manifest. This may cause errors or unexpected behavior.", 334 e 335 ) 336 } 337 callback.onManifestCompleted(Manifest.fromManifestJson(manifestJson)) 338 // ReactInstanceManagerBuilder accepts embedded assets as strings with "assets://" prefixed 339 val launchAssetFile = launcher.launchAssetFile ?: "assets://" + launcher.bundleAssetName 340 callback.onBundleCompleted(launchAssetFile) 341 } 342 343 @Throws(JSONException::class) processManifestJsonnull344 private fun processManifestJson(manifestJson: JSONObject): JSONObject { 345 val parsedManifestUrl = Uri.parse(manifestUrl) 346 347 // If legacy manifest is not yet verified, served by a third party, not standalone, and not an anonymous experience 348 // then scope it locally by using the manifest URL as a scopeKey (id) and consider it verified. 349 if (!manifestJson.optBoolean(ExponentManifest.MANIFEST_IS_VERIFIED_KEY, false) && 350 isThirdPartyHosted(parsedManifestUrl) && 351 !Constants.isStandaloneApp() && 352 !exponentManifest.isAnonymousExperience(Manifest.fromManifestJson(manifestJson)) && 353 Manifest.fromManifestJson(manifestJson) is LegacyManifest 354 ) { 355 // for https urls, sandboxed id is of form quinlanj.github.io/myProj-myApp 356 // for http urls, sandboxed id is of form UNVERIFIED-quinlanj.github.io/myProj-myApp 357 val protocol = parsedManifestUrl.scheme 358 val securityPrefix = if (protocol == "https" || protocol == "exps") "" else "UNVERIFIED-" 359 val path = if (parsedManifestUrl.path != null) parsedManifestUrl.path else "" 360 val slug = manifestJson.getNullable<String>(ExponentManifest.MANIFEST_SLUG) ?: "" 361 val sandboxedId = securityPrefix + parsedManifestUrl.host + path + "-" + slug 362 manifestJson.put(ExponentManifest.MANIFEST_ID_KEY, sandboxedId) 363 manifestJson.put(ExponentManifest.MANIFEST_IS_VERIFIED_KEY, true) 364 } 365 366 // all standalone apps are considered verified 367 if (Constants.isStandaloneApp()) { 368 manifestJson.put(ExponentManifest.MANIFEST_IS_VERIFIED_KEY, true) 369 } 370 371 // if the manifest is scoped to a random anonymous scope key, automatically verify it 372 if (exponentManifest.isAnonymousExperience(Manifest.fromManifestJson(manifestJson))) { 373 manifestJson.put(ExponentManifest.MANIFEST_IS_VERIFIED_KEY, true) 374 } 375 376 // otherwise set verified to false 377 if (!manifestJson.has(ExponentManifest.MANIFEST_IS_VERIFIED_KEY)) { 378 manifestJson.put(ExponentManifest.MANIFEST_IS_VERIFIED_KEY, false) 379 } 380 381 return manifestJson 382 } 383 isThirdPartyHostednull384 private fun isThirdPartyHosted(uri: Uri): Boolean { 385 val host = uri.host 386 return !( 387 host == "exp.host" || host == "expo.io" || host == "exp.direct" || host == "expo.test" || 388 host!!.endsWith(".exp.host") || host.endsWith(".expo.io") || host.endsWith(".exp.direct") || host.endsWith( 389 ".expo.test" 390 ) 391 ) 392 } 393 setShouldShowAppLoaderStatusnull394 private fun setShouldShowAppLoaderStatus(manifest: Manifest) { 395 // we don't want to show the cached experience alert when Updates.reloadAsync() is called 396 if (useCacheOnly) { 397 shouldShowAppLoaderStatus = false 398 return 399 } 400 shouldShowAppLoaderStatus = !manifest.isDevelopmentSilentLaunch() 401 } 402 403 // XDL expects the full "exponent-" header names 404 private val requestHeaders: Map<String, String?> 405 get() { 406 val headers = mutableMapOf<String, String>() 407 headers["Expo-Updates-Environment"] = clientEnvironment 408 headers["Expo-Client-Environment"] = clientEnvironment 409 val versionName = ExpoViewKernel.instance.versionName 410 if (versionName != null) { 411 headers["Exponent-Version"] = versionName 412 } 413 val sessionSecret = exponentSharedPreferences.sessionSecret 414 if (sessionSecret != null) { 415 headers["Expo-Session"] = sessionSecret 416 } 417 418 // XDL expects the full "exponent-" header names 419 headers["Exponent-Accept-Signature"] = "true" 420 headers["Exponent-Platform"] = "android" 421 if (KernelConfig.FORCE_UNVERSIONED_PUBLISHED_EXPERIENCES) { 422 headers["Exponent-SDK-Version"] = "UNVERSIONED" 423 } else { 424 headers["Exponent-SDK-Version"] = Constants.SDK_VERSIONS 425 } 426 return headers 427 } 428 429 private val isRunningOnEmulator: Boolean 430 get() = EmulatorUtilities.isRunningOnEmulator() 431 432 private val clientEnvironment: String 433 get() = if (Constants.isStandaloneApp()) { 434 "STANDALONE" 435 } else if (EmulatorUtilities.isRunningOnEmulator()) { 436 "EXPO_SIMULATOR" 437 } else { 438 "EXPO_DEVICE" 439 } 440 isValidSdkVersionnull441 private fun isValidSdkVersion(sdkVersion: String?): Boolean { 442 if (sdkVersion == null) { 443 return false 444 } 445 if (RNObject.UNVERSIONED == sdkVersion) { 446 return true 447 } 448 for (version in Constants.SDK_VERSIONS_LIST) { 449 if (version == sdkVersion) { 450 return true 451 } 452 } 453 return false 454 } 455 formatExceptionForIncompatibleSdknull456 private fun formatExceptionForIncompatibleSdk(sdkVersion: String?): ManifestException { 457 val errorJson = JSONObject() 458 try { 459 errorJson.put("message", "Invalid SDK version") 460 if (sdkVersion == null) { 461 errorJson.put("errorCode", "NO_SDK_VERSION_SPECIFIED") 462 } else if (ABIVersion.toNumber(sdkVersion) > ABIVersion.toNumber(Constants.SDK_VERSIONS_LIST[0])) { 463 errorJson.put("errorCode", "EXPERIENCE_SDK_VERSION_TOO_NEW") 464 } else { 465 errorJson.put("errorCode", "EXPERIENCE_SDK_VERSION_OUTDATED") 466 errorJson.put( 467 "metadata", 468 JSONObject().put( 469 "availableSDKVersions", 470 JSONArray().put(sdkVersion) 471 ) 472 ) 473 } 474 } catch (e: Exception) { 475 Log.e(TAG, "Failed to format error message for incompatible SDK version", e) 476 } 477 return ManifestException(Exception("Incompatible SDK version"), manifestUrl, errorJson) 478 } 479 480 companion object { 481 private val TAG = ExpoUpdatesAppLoader::class.java.simpleName 482 const val UPDATES_EVENT_NAME = "Expo.nativeUpdatesEvent" 483 } 484 485 init { 486 NativeModuleDepsProvider.instance.inject(ExpoUpdatesAppLoader::class.java, this) 487 } 488 } 489