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