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