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