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