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