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.manifest.EmbeddedManifest 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[UpdatesConfiguration.UPDATES_CONFIGURATION_EXPECTS_EXPO_SIGNED_MANIFEST] = true 142 val configuration = UpdatesConfiguration(null, 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 = EmbeddedManifest.get(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 // ReactInstanceManagerBuilder accepts embedded assets as strings with "assets://" prefixed 312 val launchAssetFile = launcher.launchAssetFile ?: "assets://" + launcher.bundleAssetName 313 callback.onBundleCompleted(launchAssetFile) 314 } 315 316 @Throws(JSONException::class) 317 private fun processManifestJson(manifestJson: JSONObject): JSONObject { 318 val parsedManifestUrl = Uri.parse(manifestUrl) 319 320 // If legacy manifest is not yet verified, served by a third party, not standalone, and not an anonymous experience 321 // then scope it locally by using the manifest URL as a scopeKey (id) and consider it verified. 322 if (!manifestJson.optBoolean(ExponentManifest.MANIFEST_IS_VERIFIED_KEY, false) && 323 isThirdPartyHosted(parsedManifestUrl) && 324 !Constants.isStandaloneApp() && 325 !exponentManifest.isAnonymousExperience(Manifest.fromManifestJson(manifestJson)) && 326 Manifest.fromManifestJson(manifestJson) is LegacyManifest 327 ) { 328 // for https urls, sandboxed id is of form quinlanj.github.io/myProj-myApp 329 // for http urls, sandboxed id is of form UNVERIFIED-quinlanj.github.io/myProj-myApp 330 val protocol = parsedManifestUrl.scheme 331 val securityPrefix = if (protocol == "https" || protocol == "exps") "" else "UNVERIFIED-" 332 val path = if (parsedManifestUrl.path != null) parsedManifestUrl.path else "" 333 val slug = manifestJson.getNullable<String>(ExponentManifest.MANIFEST_SLUG) ?: "" 334 val sandboxedId = securityPrefix + parsedManifestUrl.host + path + "-" + slug 335 manifestJson.put(ExponentManifest.MANIFEST_ID_KEY, sandboxedId) 336 manifestJson.put(ExponentManifest.MANIFEST_IS_VERIFIED_KEY, true) 337 } 338 339 // all standalone apps are considered verified 340 if (Constants.isStandaloneApp()) { 341 manifestJson.put(ExponentManifest.MANIFEST_IS_VERIFIED_KEY, true) 342 } 343 344 // if the manifest is scoped to a random anonymous scope key, automatically verify it 345 if (exponentManifest.isAnonymousExperience(Manifest.fromManifestJson(manifestJson))) { 346 manifestJson.put(ExponentManifest.MANIFEST_IS_VERIFIED_KEY, true) 347 } 348 349 // otherwise set verified to false 350 if (!manifestJson.has(ExponentManifest.MANIFEST_IS_VERIFIED_KEY)) { 351 manifestJson.put(ExponentManifest.MANIFEST_IS_VERIFIED_KEY, false) 352 } 353 354 return manifestJson 355 } 356 357 private fun isThirdPartyHosted(uri: Uri): Boolean { 358 val host = uri.host 359 return !( 360 host == "exp.host" || host == "expo.io" || host == "exp.direct" || host == "expo.test" || 361 host!!.endsWith(".exp.host") || host.endsWith(".expo.io") || host.endsWith(".exp.direct") || host.endsWith( 362 ".expo.test" 363 ) 364 ) 365 } 366 367 private fun setShouldShowAppLoaderStatus(manifest: Manifest) { 368 // we don't want to show the cached experience alert when Updates.reloadAsync() is called 369 if (useCacheOnly) { 370 shouldShowAppLoaderStatus = false 371 return 372 } 373 shouldShowAppLoaderStatus = !manifest.isDevelopmentSilentLaunch() 374 } 375 376 // XDL expects the full "exponent-" header names 377 private val requestHeaders: Map<String, String?> 378 get() { 379 val headers = mutableMapOf<String, String>() 380 headers["Expo-Updates-Environment"] = clientEnvironment 381 headers["Expo-Client-Environment"] = clientEnvironment 382 val versionName = ExpoViewKernel.instance.versionName 383 if (versionName != null) { 384 headers["Exponent-Version"] = versionName 385 } 386 val sessionSecret = exponentSharedPreferences.sessionSecret 387 if (sessionSecret != null) { 388 headers["Expo-Session"] = sessionSecret 389 } 390 391 // XDL expects the full "exponent-" header names 392 headers["Exponent-Accept-Signature"] = "true" 393 headers["Exponent-Platform"] = "android" 394 if (KernelConfig.FORCE_UNVERSIONED_PUBLISHED_EXPERIENCES) { 395 headers["Exponent-SDK-Version"] = "UNVERSIONED" 396 } else { 397 headers["Exponent-SDK-Version"] = Constants.SDK_VERSIONS 398 } 399 return headers 400 } 401 402 private val clientEnvironment: String 403 get() = if (Constants.isStandaloneApp()) { 404 "STANDALONE" 405 } else if (Build.FINGERPRINT.contains("vbox") || Build.FINGERPRINT.contains("generic")) { 406 "EXPO_SIMULATOR" 407 } else { 408 "EXPO_DEVICE" 409 } 410 411 private fun isValidSdkVersion(sdkVersion: String?): Boolean { 412 if (sdkVersion == null) { 413 return false 414 } 415 if (RNObject.UNVERSIONED == sdkVersion) { 416 return true 417 } 418 for (version in Constants.SDK_VERSIONS_LIST) { 419 if (version == sdkVersion) { 420 return true 421 } 422 } 423 return false 424 } 425 426 private fun formatExceptionForIncompatibleSdk(sdkVersion: String): ManifestException { 427 val errorJson = JSONObject() 428 try { 429 errorJson.put("message", "Invalid SDK version") 430 if (ABIVersion.toNumber(sdkVersion) > ABIVersion.toNumber(Constants.SDK_VERSIONS_LIST[0])) { 431 errorJson.put("errorCode", "EXPERIENCE_SDK_VERSION_TOO_NEW") 432 } else { 433 errorJson.put("errorCode", "EXPERIENCE_SDK_VERSION_OUTDATED") 434 errorJson.put( 435 "metadata", 436 JSONObject().put( 437 "availableSDKVersions", 438 JSONArray().put(sdkVersion) 439 ) 440 ) 441 } 442 } catch (e: Exception) { 443 Log.e(TAG, "Failed to format error message for incompatible SDK version", e) 444 } 445 return ManifestException(Exception("Incompatible SDK version"), manifestUrl, errorJson) 446 } 447 448 companion object { 449 private val TAG = ExpoUpdatesAppLoader::class.java.simpleName 450 const val UPDATES_EVENT_NAME = "Expo.nativeUpdatesEvent" 451 } 452 453 init { 454 NativeModuleDepsProvider.instance.inject(ExpoUpdatesAppLoader::class.java, this) 455 } 456 } 457