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