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