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 } 167 168 val configuration = UpdatesConfiguration(null, configMap) 169 val sdkVersionsList = mutableListOf<String>().apply { 170 (Constants.SDK_VERSIONS_LIST + listOf(RNObject.UNVERSIONED)).forEach { 171 add(it) 172 add("exposdk:$it") 173 } 174 } 175 val selectionPolicy = SelectionPolicy( 176 LauncherSelectionPolicyFilterAware(sdkVersionsList), 177 LoaderSelectionPolicyFilterAware(), 178 ReaperSelectionPolicyDevelopmentClient() 179 ) 180 val directory: File = try { 181 UpdatesUtils.getOrCreateUpdatesDirectory(context) 182 } catch (e: Exception) { 183 callback.onError(e) 184 return 185 } 186 startLoaderTask(configuration, directory, selectionPolicy, context) 187 } 188 189 private fun startLoaderTask( 190 configuration: UpdatesConfiguration, 191 directory: File, 192 selectionPolicy: SelectionPolicy, 193 context: Context 194 ) { 195 updatesConfiguration = configuration 196 updatesDirectory = directory 197 this.selectionPolicy = selectionPolicy 198 if (!configuration.isEnabled) { 199 launchWithNoDatabase(context, null) 200 return 201 } 202 LoaderTask( 203 configuration, 204 databaseHolder, 205 directory, 206 fileDownloader, 207 selectionPolicy, 208 object : LoaderTaskCallback { 209 private var didAbort = false 210 override fun onFailure(e: Exception) { 211 if (Constants.isStandaloneApp()) { 212 isEmergencyLaunch = true 213 launchWithNoDatabase(context, e) 214 } else { 215 if (didAbort) { 216 return 217 } 218 var exception = e 219 try { 220 val errorJson = JSONObject(e.message!!) 221 exception = ManifestException(e, manifestUrl, errorJson) 222 } catch (ex: Exception) { 223 // do nothing, expected if the error payload does not come from a conformant server 224 } 225 callback.onError(exception) 226 } 227 } 228 229 override fun onCachedUpdateLoaded(update: UpdateEntity): Boolean { 230 val manifest = Manifest.fromManifestJson(update.manifest!!) 231 setShouldShowAppLoaderStatus(manifest) 232 if (manifest.isUsingDeveloperTool()) { 233 return false 234 } else { 235 try { 236 val experienceKey = ExperienceKey.fromManifest(manifest) 237 // if previous run of this app failed due to a loading error, we want to make sure to check for remote updates 238 val experienceMetadata = exponentSharedPreferences.getExperienceMetadata(experienceKey) 239 if (experienceMetadata != null && experienceMetadata.optBoolean( 240 ExponentSharedPreferences.EXPERIENCE_METADATA_LOADING_ERROR 241 ) 242 ) { 243 return false 244 } 245 } catch (e: Exception) { 246 return true 247 } 248 } 249 return true 250 } 251 252 override fun onRemoteUpdateManifestLoaded(updateManifest: UpdateManifest) { 253 // expo-cli does not always respect our SDK version headers and respond with a compatible update or an error 254 // so we need to check the compatibility here 255 val sdkVersion = updateManifest.manifest.getSDKVersion() 256 if (!isValidSdkVersion(sdkVersion)) { 257 callback.onError(formatExceptionForIncompatibleSdk(sdkVersion)) 258 didAbort = true 259 return 260 } 261 setShouldShowAppLoaderStatus(updateManifest.manifest) 262 callback.onOptimisticManifest(updateManifest.manifest) 263 updateStatus(AppLoaderStatus.DOWNLOADING_NEW_UPDATE) 264 } 265 266 override fun onSuccess(launcher: Launcher, isUpToDate: Boolean) { 267 if (didAbort) { 268 return 269 } 270 this@ExpoUpdatesAppLoader.launcher = launcher 271 this@ExpoUpdatesAppLoader.isUpToDate = isUpToDate 272 try { 273 val manifestJson = processManifestJson(launcher.launchedUpdate!!.manifest!!) 274 val manifest = Manifest.fromManifestJson(manifestJson) 275 callback.onManifestCompleted(manifest) 276 277 // ReactAndroid will load the bundle on its own in development mode 278 if (!manifest.isDevelopmentMode()) { 279 callback.onBundleCompleted(launcher.launchAssetFile!!) 280 } 281 } catch (e: Exception) { 282 callback.onError(e) 283 } 284 } 285 286 override fun onBackgroundUpdateFinished( 287 status: BackgroundUpdateStatus, 288 update: UpdateEntity?, 289 exception: Exception? 290 ) { 291 if (didAbort) { 292 return 293 } 294 try { 295 val jsonParams = JSONObject() 296 when (status) { 297 BackgroundUpdateStatus.ERROR -> { 298 if (exception == null) { 299 throw AssertionError("Background update with error status must have a nonnull exception object") 300 } 301 jsonParams.put("type", UPDATE_ERROR_EVENT) 302 jsonParams.put("message", exception.message) 303 } 304 BackgroundUpdateStatus.UPDATE_AVAILABLE -> { 305 if (update == null) { 306 throw AssertionError("Background update with error status must have a nonnull update object") 307 } 308 jsonParams.put("type", UPDATE_AVAILABLE_EVENT) 309 jsonParams.put("manifestString", update.manifest.toString()) 310 } 311 BackgroundUpdateStatus.NO_UPDATE_AVAILABLE -> { 312 jsonParams.put("type", UPDATE_NO_UPDATE_AVAILABLE_EVENT) 313 } 314 } 315 callback.emitEvent(jsonParams) 316 } catch (e: Exception) { 317 Log.e(TAG, "Failed to emit event to JS", e) 318 } 319 } 320 } 321 ).start(context) 322 } 323 324 private fun launchWithNoDatabase(context: Context, e: Exception?) { 325 this.launcher = NoDatabaseLauncher(context, updatesConfiguration, e) 326 var manifestJson = EmbeddedManifest.get(context, updatesConfiguration)!!.manifest.getRawJson() 327 try { 328 manifestJson = processManifestJson(manifestJson) 329 } catch (ex: Exception) { 330 Log.e( 331 TAG, 332 "Failed to process manifest; attempting to launch with raw manifest. This may cause errors or unexpected behavior.", 333 e 334 ) 335 } 336 callback.onManifestCompleted(Manifest.fromManifestJson(manifestJson)) 337 // ReactInstanceManagerBuilder accepts embedded assets as strings with "assets://" prefixed 338 val launchAssetFile = launcher.launchAssetFile ?: "assets://" + launcher.bundleAssetName 339 callback.onBundleCompleted(launchAssetFile) 340 } 341 342 @Throws(JSONException::class) 343 private fun processManifestJson(manifestJson: JSONObject): JSONObject { 344 val parsedManifestUrl = Uri.parse(manifestUrl) 345 346 // If legacy manifest is not yet verified, served by a third party, not standalone, and not an anonymous experience 347 // then scope it locally by using the manifest URL as a scopeKey (id) and consider it verified. 348 if (!manifestJson.optBoolean(ExponentManifest.MANIFEST_IS_VERIFIED_KEY, false) && 349 isThirdPartyHosted(parsedManifestUrl) && 350 !Constants.isStandaloneApp() && 351 !exponentManifest.isAnonymousExperience(Manifest.fromManifestJson(manifestJson)) && 352 Manifest.fromManifestJson(manifestJson) is LegacyManifest 353 ) { 354 // for https urls, sandboxed id is of form quinlanj.github.io/myProj-myApp 355 // for http urls, sandboxed id is of form UNVERIFIED-quinlanj.github.io/myProj-myApp 356 val protocol = parsedManifestUrl.scheme 357 val securityPrefix = if (protocol == "https" || protocol == "exps") "" else "UNVERIFIED-" 358 val path = if (parsedManifestUrl.path != null) parsedManifestUrl.path else "" 359 val slug = manifestJson.getNullable<String>(ExponentManifest.MANIFEST_SLUG) ?: "" 360 val sandboxedId = securityPrefix + parsedManifestUrl.host + path + "-" + slug 361 manifestJson.put(ExponentManifest.MANIFEST_ID_KEY, sandboxedId) 362 manifestJson.put(ExponentManifest.MANIFEST_IS_VERIFIED_KEY, true) 363 } 364 365 // all standalone apps are considered verified 366 if (Constants.isStandaloneApp()) { 367 manifestJson.put(ExponentManifest.MANIFEST_IS_VERIFIED_KEY, true) 368 } 369 370 // if the manifest is scoped to a random anonymous scope key, automatically verify it 371 if (exponentManifest.isAnonymousExperience(Manifest.fromManifestJson(manifestJson))) { 372 manifestJson.put(ExponentManifest.MANIFEST_IS_VERIFIED_KEY, true) 373 } 374 375 // otherwise set verified to false 376 if (!manifestJson.has(ExponentManifest.MANIFEST_IS_VERIFIED_KEY)) { 377 manifestJson.put(ExponentManifest.MANIFEST_IS_VERIFIED_KEY, false) 378 } 379 380 return manifestJson 381 } 382 383 private fun isThirdPartyHosted(uri: Uri): Boolean { 384 val host = uri.host 385 return !( 386 host == "exp.host" || host == "expo.io" || host == "exp.direct" || host == "expo.test" || 387 host!!.endsWith(".exp.host") || host.endsWith(".expo.io") || host.endsWith(".exp.direct") || host.endsWith( 388 ".expo.test" 389 ) 390 ) 391 } 392 393 private fun setShouldShowAppLoaderStatus(manifest: Manifest) { 394 // we don't want to show the cached experience alert when Updates.reloadAsync() is called 395 if (useCacheOnly) { 396 shouldShowAppLoaderStatus = false 397 return 398 } 399 shouldShowAppLoaderStatus = !manifest.isDevelopmentSilentLaunch() 400 } 401 402 // XDL expects the full "exponent-" header names 403 private val requestHeaders: Map<String, String?> 404 get() { 405 val headers = mutableMapOf<String, String>() 406 headers["Expo-Updates-Environment"] = clientEnvironment 407 headers["Expo-Client-Environment"] = clientEnvironment 408 val versionName = ExpoViewKernel.instance.versionName 409 if (versionName != null) { 410 headers["Exponent-Version"] = versionName 411 } 412 val sessionSecret = exponentSharedPreferences.sessionSecret 413 if (sessionSecret != null) { 414 headers["Expo-Session"] = sessionSecret 415 } 416 417 // XDL expects the full "exponent-" header names 418 headers["Exponent-Accept-Signature"] = "true" 419 headers["Exponent-Platform"] = "android" 420 if (KernelConfig.FORCE_UNVERSIONED_PUBLISHED_EXPERIENCES) { 421 headers["Exponent-SDK-Version"] = "UNVERSIONED" 422 } else { 423 headers["Exponent-SDK-Version"] = Constants.SDK_VERSIONS 424 } 425 return headers 426 } 427 428 private val isRunningOnEmulator: Boolean 429 get() = EmulatorUtilities.isRunningOnEmulator() 430 431 private val clientEnvironment: String 432 get() = if (Constants.isStandaloneApp()) { 433 "STANDALONE" 434 } else if (EmulatorUtilities.isRunningOnEmulator()) { 435 "EXPO_SIMULATOR" 436 } else { 437 "EXPO_DEVICE" 438 } 439 440 private fun isValidSdkVersion(sdkVersion: String?): Boolean { 441 if (sdkVersion == null) { 442 return false 443 } 444 if (RNObject.UNVERSIONED == sdkVersion) { 445 return true 446 } 447 for (version in Constants.SDK_VERSIONS_LIST) { 448 if (version == sdkVersion) { 449 return true 450 } 451 } 452 return false 453 } 454 455 private fun formatExceptionForIncompatibleSdk(sdkVersion: String?): ManifestException { 456 val errorJson = JSONObject() 457 try { 458 errorJson.put("message", "Invalid SDK version") 459 if (sdkVersion == null) { 460 errorJson.put("errorCode", "NO_SDK_VERSION_SPECIFIED") 461 } else if (ABIVersion.toNumber(sdkVersion) > ABIVersion.toNumber(Constants.SDK_VERSIONS_LIST[0])) { 462 errorJson.put("errorCode", "EXPERIENCE_SDK_VERSION_TOO_NEW") 463 } else { 464 errorJson.put("errorCode", "EXPERIENCE_SDK_VERSION_OUTDATED") 465 errorJson.put( 466 "metadata", 467 JSONObject().put( 468 "availableSDKVersions", 469 JSONArray().put(sdkVersion) 470 ) 471 ) 472 } 473 } catch (e: Exception) { 474 Log.e(TAG, "Failed to format error message for incompatible SDK version", e) 475 } 476 return ManifestException(Exception("Incompatible SDK version"), manifestUrl, errorJson) 477 } 478 479 companion object { 480 private val TAG = ExpoUpdatesAppLoader::class.java.simpleName 481 const val UPDATES_EVENT_NAME = "Expo.nativeUpdatesEvent" 482 } 483 484 init { 485 NativeModuleDepsProvider.instance.inject(ExpoUpdatesAppLoader::class.java, this) 486 } 487 } 488