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