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