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