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.manifest.EmbeddedManifest
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[UpdatesConfiguration.UPDATES_CONFIGURATION_EXPECTS_EXPO_SIGNED_MANIFEST] = true
142     val configuration = UpdatesConfiguration(null, 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 = EmbeddedManifest.get(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     // ReactInstanceManagerBuilder accepts embedded assets as strings with "assets://" prefixed
312     val launchAssetFile = launcher.launchAssetFile ?: "assets://" + launcher.bundleAssetName
313     callback.onBundleCompleted(launchAssetFile)
314   }
315 
316   @Throws(JSONException::class)
317   private fun processManifestJson(manifestJson: JSONObject): JSONObject {
318     val parsedManifestUrl = Uri.parse(manifestUrl)
319 
320     // If legacy manifest is not yet verified, served by a third party, not standalone, and not an anonymous experience
321     // then scope it locally by using the manifest URL as a scopeKey (id) and consider it verified.
322     if (!manifestJson.optBoolean(ExponentManifest.MANIFEST_IS_VERIFIED_KEY, false) &&
323       isThirdPartyHosted(parsedManifestUrl) &&
324       !Constants.isStandaloneApp() &&
325       !exponentManifest.isAnonymousExperience(Manifest.fromManifestJson(manifestJson)) &&
326       Manifest.fromManifestJson(manifestJson) is LegacyManifest
327     ) {
328       // for https urls, sandboxed id is of form quinlanj.github.io/myProj-myApp
329       // for http urls, sandboxed id is of form UNVERIFIED-quinlanj.github.io/myProj-myApp
330       val protocol = parsedManifestUrl.scheme
331       val securityPrefix = if (protocol == "https" || protocol == "exps") "" else "UNVERIFIED-"
332       val path = if (parsedManifestUrl.path != null) parsedManifestUrl.path else ""
333       val slug = manifestJson.getNullable<String>(ExponentManifest.MANIFEST_SLUG) ?: ""
334       val sandboxedId = securityPrefix + parsedManifestUrl.host + path + "-" + slug
335       manifestJson.put(ExponentManifest.MANIFEST_ID_KEY, sandboxedId)
336       manifestJson.put(ExponentManifest.MANIFEST_IS_VERIFIED_KEY, true)
337     }
338 
339     // all standalone apps are considered verified
340     if (Constants.isStandaloneApp()) {
341       manifestJson.put(ExponentManifest.MANIFEST_IS_VERIFIED_KEY, true)
342     }
343 
344     // if the manifest is scoped to a random anonymous scope key, automatically verify it
345     if (exponentManifest.isAnonymousExperience(Manifest.fromManifestJson(manifestJson))) {
346       manifestJson.put(ExponentManifest.MANIFEST_IS_VERIFIED_KEY, true)
347     }
348 
349     // otherwise set verified to false
350     if (!manifestJson.has(ExponentManifest.MANIFEST_IS_VERIFIED_KEY)) {
351       manifestJson.put(ExponentManifest.MANIFEST_IS_VERIFIED_KEY, false)
352     }
353 
354     return manifestJson
355   }
356 
357   private fun isThirdPartyHosted(uri: Uri): Boolean {
358     val host = uri.host
359     return !(
360       host == "exp.host" || host == "expo.io" || host == "exp.direct" || host == "expo.test" ||
361         host!!.endsWith(".exp.host") || host.endsWith(".expo.io") || host.endsWith(".exp.direct") || host.endsWith(
362         ".expo.test"
363       )
364       )
365   }
366 
367   private fun setShouldShowAppLoaderStatus(manifest: Manifest) {
368     // we don't want to show the cached experience alert when Updates.reloadAsync() is called
369     if (useCacheOnly) {
370       shouldShowAppLoaderStatus = false
371       return
372     }
373     shouldShowAppLoaderStatus = !manifest.isDevelopmentSilentLaunch()
374   }
375 
376   // XDL expects the full "exponent-" header names
377   private val requestHeaders: Map<String, String?>
378     get() {
379       val headers = mutableMapOf<String, String>()
380       headers["Expo-Updates-Environment"] = clientEnvironment
381       headers["Expo-Client-Environment"] = clientEnvironment
382       val versionName = ExpoViewKernel.instance.versionName
383       if (versionName != null) {
384         headers["Exponent-Version"] = versionName
385       }
386       val sessionSecret = exponentSharedPreferences.sessionSecret
387       if (sessionSecret != null) {
388         headers["Expo-Session"] = sessionSecret
389       }
390 
391       // XDL expects the full "exponent-" header names
392       headers["Exponent-Accept-Signature"] = "true"
393       headers["Exponent-Platform"] = "android"
394       if (KernelConfig.FORCE_UNVERSIONED_PUBLISHED_EXPERIENCES) {
395         headers["Exponent-SDK-Version"] = "UNVERSIONED"
396       } else {
397         headers["Exponent-SDK-Version"] = Constants.SDK_VERSIONS
398       }
399       return headers
400     }
401 
402   private val clientEnvironment: String
403     get() = if (Constants.isStandaloneApp()) {
404       "STANDALONE"
405     } else if (Build.FINGERPRINT.contains("vbox") || Build.FINGERPRINT.contains("generic")) {
406       "EXPO_SIMULATOR"
407     } else {
408       "EXPO_DEVICE"
409     }
410 
411   private fun isValidSdkVersion(sdkVersion: String?): Boolean {
412     if (sdkVersion == null) {
413       return false
414     }
415     if (RNObject.UNVERSIONED == sdkVersion) {
416       return true
417     }
418     for (version in Constants.SDK_VERSIONS_LIST) {
419       if (version == sdkVersion) {
420         return true
421       }
422     }
423     return false
424   }
425 
426   private fun formatExceptionForIncompatibleSdk(sdkVersion: String): ManifestException {
427     val errorJson = JSONObject()
428     try {
429       errorJson.put("message", "Invalid SDK version")
430       if (ABIVersion.toNumber(sdkVersion) > ABIVersion.toNumber(Constants.SDK_VERSIONS_LIST[0])) {
431         errorJson.put("errorCode", "EXPERIENCE_SDK_VERSION_TOO_NEW")
432       } else {
433         errorJson.put("errorCode", "EXPERIENCE_SDK_VERSION_OUTDATED")
434         errorJson.put(
435           "metadata",
436           JSONObject().put(
437             "availableSDKVersions",
438             JSONArray().put(sdkVersion)
439           )
440         )
441       }
442     } catch (e: Exception) {
443       Log.e(TAG, "Failed to format error message for incompatible SDK version", e)
444     }
445     return ManifestException(Exception("Incompatible SDK version"), manifestUrl, errorJson)
446   }
447 
448   companion object {
449     private val TAG = ExpoUpdatesAppLoader::class.java.simpleName
450     const val UPDATES_EVENT_NAME = "Expo.nativeUpdatesEvent"
451   }
452 
453   init {
454     NativeModuleDepsProvider.instance.inject(ExpoUpdatesAppLoader::class.java, this)
455   }
456 }
457