1 // Copyright 2015-present 650 Industries. All rights reserved.
2 package host.exp.exponent
3 
4 import android.content.Context
5 import android.graphics.Bitmap
6 import android.graphics.BitmapFactory
7 import android.graphics.Color
8 import android.net.Uri
9 import android.os.AsyncTask
10 import android.os.Debug
11 import android.text.TextUtils
12 import android.util.Log
13 import android.util.LruCache
14 import expo.modules.updates.manifest.ManifestFactory
15 import expo.modules.updates.manifest.raw.InternalJSONMutator
16 import expo.modules.updates.manifest.raw.RawManifest
17 import host.exp.exponent.analytics.Analytics
18 import host.exp.exponent.analytics.EXL
19 import host.exp.exponent.exceptions.ManifestException
20 import host.exp.exponent.generated.ExponentBuildConstants
21 import host.exp.exponent.kernel.Crypto
22 import host.exp.exponent.kernel.ExponentUrls
23 import host.exp.exponent.kernel.KernelProvider
24 import host.exp.exponent.network.ExpoHeaders
25 import host.exp.exponent.network.ExpoResponse
26 import host.exp.exponent.network.ExponentHttpClient.SafeCallback
27 import host.exp.exponent.network.ExponentNetwork
28 import host.exp.exponent.storage.ExponentSharedPreferences
29 import host.exp.exponent.utils.ColorParser
30 import host.exp.expoview.R
31 import okhttp3.CacheControl
32 import org.apache.commons.io.IOUtils
33 import org.json.JSONArray
34 import org.json.JSONException
35 import org.json.JSONObject
36 import java.io.IOException
37 import java.net.HttpURLConnection
38 import java.net.URI
39 import java.net.URISyntaxException
40 import java.net.URL
41 import java.text.DateFormat
42 import java.text.ParseException
43 import java.text.SimpleDateFormat
44 import java.util.*
45 import javax.inject.Inject
46 import javax.inject.Singleton
47 import kotlin.math.max
48 
49 @Singleton
50 class ExponentManifest @Inject constructor(
51   var context: Context,
52   var exponentNetwork: ExponentNetwork,
53   var crypto: Crypto,
54   var exponentSharedPreferences: ExponentSharedPreferences
55 ) {
56   interface ManifestListener {
57     fun onCompleted(manifest: RawManifest)
58     fun onError(e: Exception)
59     fun onError(e: String)
60   }
61 
62   interface BitmapListener {
63     fun onLoadBitmap(bitmap: Bitmap?)
64   }
65 
66   private val memoryCache: LruCache<String, Bitmap>
67 
68   fun httpManifestUrl(manifestUrl: String): Uri {
69     return httpManifestUrlBuilder(manifestUrl).build()
70   }
71 
72   private fun httpManifestUrlBuilder(manifestUrl: String): Uri.Builder {
73     var realManifestUrl = manifestUrl
74     if (manifestUrl.contains(REDIRECT_SNIPPET)) {
75       // Redirect urls look like "https://exp.host/--/to-exp/exp%3A%2F%2Fgj-5x6.jesse.internal.exp.direct%3A80".
76       // Android is crazy and catches this url with this intent filter:
77       //  <data
78       //    android:host="*.exp.direct"
79       //    android:pathPattern=".*"
80       //    android:scheme="http"/>
81       //  <data
82       //    android:host="*.exp.direct"
83       //    android:pathPattern=".*"
84       //    android:scheme="https"/>
85       // so we have to add some special logic to handle that. This is than handling arbitrary HTTP 301s and 302
86       realManifestUrl = Uri.decode(
87         realManifestUrl.substring(
88           realManifestUrl.indexOf(
89             REDIRECT_SNIPPET
90           ) + REDIRECT_SNIPPET.length
91         )
92       )
93     }
94     val httpManifestUrl = ExponentUrls.toHttp(realManifestUrl)
95     val uri = Uri.parse(httpManifestUrl)
96     var newPath = uri.path
97     if (newPath == null) {
98       newPath = ""
99     }
100     val deepLinkIndex = newPath.indexOf(DEEP_LINK_SEPARATOR_WITH_SLASH)
101     if (deepLinkIndex > -1) {
102       newPath = newPath.substring(0, deepLinkIndex)
103     }
104     return uri.buildUpon().encodedPath(newPath)
105   }
106 
107   @JvmOverloads
108   fun fetchManifest(
109     manifestUrl: String,
110     listener: ManifestListener,
111     shouldWriteToCache: Boolean = true
112   ) {
113     Analytics.markEvent(Analytics.TimedEvent.STARTED_FETCHING_MANIFEST)
114     val uriBuilder = httpManifestUrlBuilder(manifestUrl)
115     if (!shouldWriteToCache) {
116       // add a dummy parameter so this doesn't overwrite the current cached manifest
117       // more correct would be to add Cache-Control: no-store header, but this doesn't seem to
118       // work correctly with requests in okhttp
119       uriBuilder.appendQueryParameter("cache", "false")
120     }
121     val httpManifestUrl = uriBuilder.build().toString()
122 
123     // Fetch manifest
124     val requestBuilder = ExponentUrls.addExponentHeadersToManifestUrl(
125       httpManifestUrl,
126       manifestUrl == Constants.INITIAL_URL,
127       exponentSharedPreferences.sessionSecret
128     ).apply {
129       header("Exponent-Accept-Signature", "true")
130       header("Expo-JSON-Error", "true")
131       cacheControl(CacheControl.FORCE_NETWORK)
132     }
133     Analytics.markEvent(Analytics.TimedEvent.STARTED_MANIFEST_NETWORK_REQUEST)
134     if (Constants.DEBUG_MANIFEST_METHOD_TRACING) {
135       Debug.startMethodTracing("manifest")
136     }
137     val request = requestBuilder.build()
138     val finalUri = request.url().toString()
139     exponentNetwork.client.callSafe(
140       request,
141       object : SafeCallback {
142         override fun onFailure(e: IOException) {
143           listener.onError(ManifestException(e, manifestUrl))
144         }
145 
146         override fun onResponse(response: ExpoResponse) {
147           // OkHttp sometimes decides to use the cache anyway here
148           val isCached = response.networkResponse() == null
149           handleManifestResponse(response, manifestUrl, finalUri, listener, false, isCached)
150         }
151 
152         override fun onCachedResponse(response: ExpoResponse, isEmbedded: Boolean) {
153           // this is only called if network is unavailable for some reason
154           handleManifestResponse(response, manifestUrl, finalUri, listener, isEmbedded, true)
155         }
156       }
157     )
158   }
159 
160   private fun handleManifestResponse(
161     response: ExpoResponse,
162     manifestUrl: String,
163     httpManifestUrl: String,
164     listener: ManifestListener,
165     isEmbedded: Boolean,
166     isCached: Boolean
167   ) {
168     if (!response.isSuccessful) {
169       val exception: ManifestException = try {
170         val errorJSON = JSONObject(response.body().string())
171         ManifestException(null, manifestUrl, errorJSON)
172       } catch (e: JSONException) {
173         ManifestException(null, manifestUrl)
174       } catch (e: IOException) {
175         ManifestException(null, manifestUrl)
176       }
177       listener.onError(exception)
178       return
179     }
180     try {
181       val manifestString = response.body().string()
182       fetchManifestStep2(
183         manifestUrl,
184         httpManifestUrl,
185         manifestString,
186         response.headers(),
187         listener,
188         isEmbedded,
189         isCached
190       )
191     } catch (e: JSONException) {
192       listener.onError(e)
193     } catch (e: IOException) {
194       listener.onError(e)
195     } catch (e: URISyntaxException) {
196       listener.onError(e)
197     }
198   }
199 
200   @Throws(JSONException::class, ParseException::class)
201   private fun newerManifest(manifest1: RawManifest, manifest2: RawManifest): RawManifest {
202     val manifest1Timestamp = manifest1.getSortTime()
203     val manifest2Timestamp = manifest2.getSortTime()
204 
205     // SimpleDateFormat on Android does not support the ISO-8601 representation of the timezone,
206     // namely, using 'Z' to represent GMT. Since all our dates here are in the same timezone,
207     // and we're just comparing them relative to each other, we can just ignore this character.
208     val formatter: DateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
209     val manifest1Date = formatter.parse(manifest1Timestamp)
210     val manifest2Date = formatter.parse(manifest2Timestamp)
211     return if (manifest1Date.after(manifest2Date)) {
212       manifest1
213     } else {
214       manifest2
215     }
216   }
217 
218   private fun isManifestSDKVersionValid(manifest: RawManifest): Boolean {
219     val sdkVersion = manifest.getSDKVersionNullable() ?: return false
220     return if (RNObject.UNVERSIONED == sdkVersion) {
221       true
222     } else {
223       for (version in Constants.SDK_VERSIONS_LIST) {
224         if (version == sdkVersion) {
225           return true
226         }
227       }
228       false
229     }
230   }
231 
232   @Throws(IOException::class)
233   private fun extractManifest(manifestString: String): JSONObject {
234     try {
235       return JSONObject(manifestString)
236     } catch (e: JSONException) {
237       // Ignore this error, try to parse manifest as array
238     }
239     try {
240       // the manifestString could be an array of manifest objects
241       // in this case, we choose the first compatible manifest in the array
242       val manifestArray = JSONArray(manifestString)
243       for (i in 0 until manifestArray.length()) {
244         val manifestCandidate = manifestArray.getJSONObject(i)
245         val sdkVersion = manifestCandidate.getString(MANIFEST_SDK_VERSION_KEY)
246         if (Constants.SDK_VERSIONS_LIST.contains(sdkVersion)) {
247           return manifestCandidate
248         }
249       }
250     } catch (e: JSONException) {
251       throw IOException(
252         "Manifest string is not a valid JSONObject or JSONArray: $manifestString",
253         e
254       )
255     }
256     throw IOException("No compatible manifest found. SDK Versions supported: " + Constants.SDK_VERSIONS + " Provided manifestString: " + manifestString)
257   }
258 
259   @Throws(JSONException::class, URISyntaxException::class, IOException::class)
260   private fun fetchManifestStep2(
261     manifestUrl: String,
262     httpManifestUrl: String,
263     manifestString: String,
264     headers: ExpoHeaders?,
265     listener: ManifestListener,
266     isEmbedded: Boolean,
267     isCached: Boolean
268   ) {
269     if (Constants.DEBUG_MANIFEST_METHOD_TRACING) {
270       Debug.stopMethodTracing()
271     }
272     if (headers != null) {
273       Analytics.markEvent(Analytics.TimedEvent.FINISHED_MANIFEST_NETWORK_REQUEST)
274     }
275     val outerManifestJson = extractManifest(manifestString)
276     val isMainShellAppExperience = manifestUrl == Constants.INITIAL_URL
277     val parsedManifestUrl = URI(manifestUrl)
278     val isManifestSigned = outerManifestJson.has(MANIFEST_STRING_KEY) && outerManifestJson.has(
279       MANIFEST_SIGNATURE_KEY
280     )
281     var manifestJson = outerManifestJson
282     if (isManifestSigned) {
283       // get inner manifest if manifest is wrapped in signature
284       manifestJson = JSONObject(outerManifestJson.getString(MANIFEST_STRING_KEY))
285     }
286     var manifest = ManifestFactory.getRawManifestFromJson(
287       manifestJson
288     )
289 
290     // if the manifest we are passed is from the cache, we need to get the embedded manifest so that
291     // we can compare them in case embedded manifest is newer (i.e. user has installed a new APK)
292     var isUsingEmbeddedManifest = isEmbedded
293     if (!isEmbedded && isCached) {
294       val embeddedResponse = exponentNetwork.client.getHardCodedResponse(httpManifestUrl)
295       if (embeddedResponse != null) {
296         try {
297           val embeddedManifest = ManifestFactory.getRawManifestFromJson(JSONObject(embeddedResponse))
298           manifest = if (!isManifestSDKVersionValid(manifest)) {
299             // if we somehow try to load a cached manifest with an invalid SDK version,
300             // fall back immediately to the embedded manifest, which should never have an
301             // invalid SDK version.
302             embeddedManifest
303           } else {
304             newerManifest(embeddedManifest, manifest)
305           }
306           isUsingEmbeddedManifest = embeddedManifest === manifest
307         } catch (e: Exception) {
308           EXL.e(TAG, e)
309         }
310       }
311     }
312     val isUsingEmbeddedManifestFinal = isUsingEmbeddedManifest
313     manifest.mutateInternalJSONInPlace(object : InternalJSONMutator {
314       override fun updateJSON(json: JSONObject) {
315         json.put(
316           MANIFEST_LOADED_FROM_CACHE_KEY, isCached || isUsingEmbeddedManifestFinal
317         )
318       }
319     })
320     if (isManifestSigned) {
321       val isOffline = !ExponentNetwork.isNetworkAvailable(context)
322       if (isAnonymousExperience(manifest) || isMainShellAppExperience || isUsingEmbeddedManifest) {
323         // Automatically verified.
324         fetchManifestStep3(manifest, true, listener)
325       } else {
326         val finalManifest = manifest
327         crypto.verifyPublicRSASignature(
328           Constants.API_HOST + "/--/manifest-public-key",
329           outerManifestJson.getString(MANIFEST_STRING_KEY),
330           outerManifestJson.getString(
331             MANIFEST_SIGNATURE_KEY
332           ),
333           object : Crypto.RSASignatureListener {
334             override fun onError(errorMessage: String?, isNetworkError: Boolean) {
335               if (isOffline && isNetworkError) {
336                 // automatically validate if offline and don't have public key
337                 // TODO: we need to evict manifest from the cache if it doesn't pass validation when online
338                 fetchManifestStep3(finalManifest, true, listener)
339               } else {
340                 Log.w(TAG, errorMessage!!)
341                 fetchManifestStep3(finalManifest, false, listener)
342               }
343             }
344 
345             override fun onCompleted(isValid: Boolean) {
346               fetchManifestStep3(finalManifest, isValid, listener)
347             }
348           }
349         )
350       }
351     } else {
352       // if we're using a cached manifest that's stored without the signature, we can assume
353       // we've already verified it previously
354       if (isCached || isUsingEmbeddedManifest || isMainShellAppExperience) {
355         fetchManifestStep3(manifest, true, listener)
356       } else if (isThirdPartyHosted(parsedManifestUrl)) {
357         // Sandbox third party apps and consider them verified
358         // for https urls, sandboxed id is of form quinlanj.github.io/myProj-myApp
359         // for http urls, sandboxed id is of form UNVERIFIED-quinlanj.github.io/myProj-myApp
360         if (!Constants.isStandaloneApp()) {
361           val protocol = parsedManifestUrl.scheme
362           val securityPrefix = if (protocol == "https" || protocol == "exps") "" else "UNVERIFIED-"
363           val path = if (parsedManifestUrl.path != null) parsedManifestUrl.path else ""
364           val slug = if (manifest.getSlug() != null) manifest.getSlug() else ""
365           val sandboxedId = securityPrefix + parsedManifestUrl.host + path + "-" + slug
366           manifest.mutateInternalJSONInPlace(object : InternalJSONMutator {
367             override fun updateJSON(json: JSONObject) {
368               json.put(
369                 MANIFEST_ID_KEY, sandboxedId
370               )
371             }
372           })
373         }
374         fetchManifestStep3(manifest, true, listener)
375       } else {
376         fetchManifestStep3(manifest, false, listener)
377       }
378     }
379     if (headers != null) {
380       val exponentServerHeader = headers[EXPONENT_SERVER_HEADER]
381       if (exponentServerHeader != null) {
382         try {
383           val eventProperties = JSONObject(exponentServerHeader)
384           Analytics.logEvent(Analytics.LOAD_DEVELOPER_MANIFEST, eventProperties)
385         } catch (e: Throwable) {
386           EXL.e(TAG, e)
387         }
388       }
389     }
390   }
391 
392   private fun isThirdPartyHosted(uri: URI): Boolean {
393     val host = uri.host
394     val isExpoHost =
395       host == "exp.host" || host == "expo.io" || host == "exp.direct" || host == "expo.test" ||
396         host.endsWith(".exp.host") || host.endsWith(".expo.io") || host.endsWith(".exp.direct") || host.endsWith(
397         ".expo.test"
398       )
399     return !isExpoHost
400   }
401 
402   private fun fetchManifestStep3(
403     manifest: RawManifest,
404     isVerified: Boolean,
405     listener: ManifestListener
406   ) {
407     try {
408       manifest.getBundleURL()
409     } catch (e: JSONException) {
410       listener.onError("No bundleUrl in manifest")
411       return
412     }
413     try {
414       manifest.mutateInternalJSONInPlace(object : InternalJSONMutator {
415         override fun updateJSON(json: JSONObject) {
416           json.put(
417             MANIFEST_IS_VERIFIED_KEY, isVerified
418           )
419         }
420       })
421     } catch (e: JSONException) {
422       listener.onError(e)
423       return
424     }
425     listener.onCompleted(manifest)
426   }
427 
428   fun loadIconBitmap(iconUrl: String?, listener: BitmapListener) {
429     val icon = getIconFromCache(iconUrl)
430     if (icon != null) {
431       listener.onLoadBitmap(icon)
432       return
433     }
434     object : AsyncTask<Void?, Void?, Bitmap>() {
435       override fun doInBackground(vararg p0: Void?): Bitmap? {
436         return loadIconTask(iconUrl)
437       }
438 
439       override fun onPostExecute(result: Bitmap?) {
440         listener.onLoadBitmap(result)
441       }
442     }.execute()
443   }
444 
445   private fun getIconFromCache(iconUrl: String?): Bitmap? {
446     return if (iconUrl == null || TextUtils.isEmpty(iconUrl)) {
447       BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher)
448     } else memoryCache[iconUrl]
449   }
450 
451   private fun loadIconTask(iconUrl: String?): Bitmap? {
452     return try {
453       // TODO: inject shared OkHttp client
454       val url = URL(iconUrl)
455       val connection = url.openConnection() as HttpURLConnection
456       connection.doInput = true
457       connection.connect()
458       val input = connection.inputStream
459       val bitmap = BitmapFactory.decodeStream(input)
460       val width = bitmap.width
461       val height = bitmap.height
462       if (width <= MAX_BITMAP_SIZE && height <= MAX_BITMAP_SIZE) {
463         memoryCache.put(iconUrl, bitmap)
464         return bitmap
465       }
466       val maxDimension = max(width, height)
467       val scaledWidth = width.toFloat() * MAX_BITMAP_SIZE / maxDimension
468       val scaledHeight = height.toFloat() * MAX_BITMAP_SIZE / maxDimension
469       val scaledBitmap =
470         Bitmap.createScaledBitmap(bitmap, scaledWidth.toInt(), scaledHeight.toInt(), true)
471       memoryCache.put(iconUrl, scaledBitmap)
472       scaledBitmap
473     } catch (e: IOException) {
474       EXL.e(TAG, e)
475       BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher)
476     } catch (e: Throwable) {
477       EXL.e(TAG, e)
478       BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher)
479     }
480   }
481 
482   fun getColorFromManifest(manifest: RawManifest): Int {
483     val colorString = manifest.getPrimaryColor()
484     return if (colorString != null && ColorParser.isValid(colorString)) {
485       Color.parseColor(colorString)
486     } else {
487       R.color.colorPrimary
488     }
489   }
490 
491   fun isAnonymousExperience(manifest: RawManifest): Boolean {
492     return try {
493       val id = manifest.getLegacyID()
494       id.startsWith(ANONYMOUS_EXPERIENCE_PREFIX)
495     } catch (e: JSONException) {
496       false
497     }
498   }
499 
500   private fun getLocalKernelManifest(): RawManifest = try {
501     val manifest = JSONObject(ExponentBuildConstants.BUILD_MACHINE_KERNEL_MANIFEST)
502     manifest.put(MANIFEST_IS_VERIFIED_KEY, true)
503     ManifestFactory.getRawManifestFromJson(manifest)
504   } catch (e: JSONException) {
505     throw RuntimeException("Can't get local manifest: $e")
506   }
507 
508   private fun getRemoteKernelManifest(): RawManifest? = try {
509     val inputStream = context.assets.open(EMBEDDED_KERNEL_MANIFEST_ASSET)
510     val jsonString = IOUtils.toString(inputStream)
511     val manifest = JSONObject(jsonString)
512     manifest.put(MANIFEST_IS_VERIFIED_KEY, true)
513     ManifestFactory.getRawManifestFromJson(manifest)
514   } catch (e: Exception) {
515     KernelProvider.instance.handleError(e)
516     null
517   }
518 
519   fun getKernelManifest(): RawManifest {
520     val manifest: RawManifest?
521     val log: String
522     if (exponentSharedPreferences.shouldUseInternetKernel()) {
523       log = "Using remote Expo kernel manifest"
524       manifest = getRemoteKernelManifest()
525     } else {
526       log = "Using local Expo kernel manifest"
527       manifest = getLocalKernelManifest()
528     }
529     if (!hasShownKernelManifestLog) {
530       hasShownKernelManifestLog = true
531       EXL.d(TAG, log + ": " + manifest.toString())
532     }
533     return manifest!!
534   }
535 
536   companion object {
537     private val TAG = ExponentManifest::class.java.simpleName
538 
539     const val MANIFEST_STRING_KEY = "manifestString"
540     const val MANIFEST_SIGNATURE_KEY = "signature"
541     const val MANIFEST_ID_KEY = "id"
542     const val MANIFEST_NAME_KEY = "name"
543     const val MANIFEST_APP_KEY_KEY = "appKey"
544     const val MANIFEST_SDK_VERSION_KEY = "sdkVersion"
545     const val MANIFEST_IS_VERIFIED_KEY = "isVerified"
546     const val MANIFEST_ICON_URL_KEY = "iconUrl"
547     const val MANIFEST_BACKGROUND_COLOR_KEY = "backgroundColor"
548     const val MANIFEST_PRIMARY_COLOR_KEY = "primaryColor"
549     const val MANIFEST_ORIENTATION_KEY = "orientation"
550     const val MANIFEST_DEVELOPER_KEY = "developer"
551     const val MANIFEST_DEVELOPER_TOOL_KEY = "tool"
552     const val MANIFEST_PACKAGER_OPTS_KEY = "packagerOpts"
553     const val MANIFEST_PACKAGER_OPTS_DEV_KEY = "dev"
554     const val MANIFEST_BUNDLE_URL_KEY = "bundleUrl"
555     const val MANIFEST_REVISION_ID_KEY = "revisionId"
556     const val MANIFEST_PUBLISHED_TIME_KEY = "publishedTime"
557     const val MANIFEST_COMMIT_TIME_KEY = "commitTime"
558     const val MANIFEST_LOADED_FROM_CACHE_KEY = "loadedFromCache"
559     const val MANIFEST_SLUG = "slug"
560     const val MANIFEST_ANDROID_INFO_KEY = "android"
561     const val MANIFEST_KEYBOARD_LAYOUT_MODE_KEY = "softwareKeyboardLayoutMode"
562 
563     // Statusbar
564     const val MANIFEST_STATUS_BAR_KEY = "androidStatusBar"
565     const val MANIFEST_STATUS_BAR_APPEARANCE = "barStyle"
566     const val MANIFEST_STATUS_BAR_BACKGROUND_COLOR = "backgroundColor"
567     const val MANIFEST_STATUS_BAR_HIDDEN = "hidden"
568     const val MANIFEST_STATUS_BAR_TRANSLUCENT = "translucent"
569 
570     // NavigationBar
571     const val MANIFEST_NAVIGATION_BAR_KEY = "androidNavigationBar"
572     const val MANIFEST_NAVIGATION_BAR_VISIBLILITY = "visible"
573     const val MANIFEST_NAVIGATION_BAR_APPEARANCE = "barStyle"
574     const val MANIFEST_NAVIGATION_BAR_BACKGROUND_COLOR = "backgroundColor"
575 
576     // Notification
577     const val MANIFEST_NOTIFICATION_INFO_KEY = "notification"
578     const val MANIFEST_NOTIFICATION_ICON_URL_KEY = "iconUrl"
579     const val MANIFEST_NOTIFICATION_COLOR_KEY = "color"
580     const val MANIFEST_NOTIFICATION_ANDROID_MODE = "androidMode"
581     const val MANIFEST_NOTIFICATION_ANDROID_COLLAPSED_TITLE = "androidCollapsedTitle"
582 
583     // Debugging
584     const val MANIFEST_DEBUGGER_HOST_KEY = "debuggerHost"
585     const val MANIFEST_MAIN_MODULE_NAME_KEY = "mainModuleName"
586 
587     // Splash
588     const val MANIFEST_SPLASH_INFO_KEY = "splash"
589     const val MANIFEST_SPLASH_IMAGE_URL_KEY = "imageUrl"
590     const val MANIFEST_SPLASH_RESIZE_MODE_KEY = "resizeMode"
591     const val MANIFEST_SPLASH_BACKGROUND_COLOR_KEY = "backgroundColor"
592 
593     // Updates
594     const val MANIFEST_UPDATES_INFO_KEY = "updates"
595     const val MANIFEST_UPDATES_TIMEOUT_KEY = "fallbackToCacheTimeout"
596     const val MANIFEST_UPDATES_CHECK_AUTOMATICALLY_KEY = "checkAutomatically"
597     const val MANIFEST_UPDATES_CHECK_AUTOMATICALLY_ON_LOAD = "ON_LOAD"
598     const val MANIFEST_UPDATES_CHECK_AUTOMATICALLY_ON_ERROR = "ON_ERROR_RECOVERY"
599 
600     // Development client
601     const val MANIFEST_DEVELOPMENT_CLIENT_KEY = "developmentClient"
602     const val MANIFEST_DEVELOPMENT_CLIENT_SILENT_LAUNCH_KEY = "silentLaunch"
603     const val DEEP_LINK_SEPARATOR = "--"
604     const val DEEP_LINK_SEPARATOR_WITH_SLASH = "--/"
605     const val QUERY_PARAM_KEY_RELEASE_CHANNEL = "release-channel"
606     const val QUERY_PARAM_KEY_EXPO_UPDATES_RUNTIME_VERSION = "runtime-version"
607     const val QUERY_PARAM_KEY_EXPO_UPDATES_CHANNEL_NAME = "channel-name"
608 
609     private const val MAX_BITMAP_SIZE = 192
610     private const val REDIRECT_SNIPPET = "exp.host/--/to-exp/"
611     private const val ANONYMOUS_EXPERIENCE_PREFIX = "@anonymous/"
612     private const val EMBEDDED_KERNEL_MANIFEST_ASSET = "kernel-manifest.json"
613     private const val EXPONENT_SERVER_HEADER = "Exponent-Server"
614 
615     private var hasShownKernelManifestLog = false
616 
617     @Throws(JSONException::class)
618     fun normalizeRawManifestInPlace(rawManifest: RawManifest, manifestUrl: String) {
619       rawManifest.mutateInternalJSONInPlace(object : InternalJSONMutator {
620         override fun updateJSON(json: JSONObject) {
621           if (!json.has(MANIFEST_ID_KEY)) {
622             json.put(MANIFEST_ID_KEY, manifestUrl)
623           }
624           if (!json.has(MANIFEST_NAME_KEY)) {
625             json.put(MANIFEST_NAME_KEY, "My New Experience")
626           }
627           if (!json.has(MANIFEST_PRIMARY_COLOR_KEY)) {
628             json.put(MANIFEST_PRIMARY_COLOR_KEY, "#023C69")
629           }
630           if (!json.has(MANIFEST_ICON_URL_KEY)) {
631             json.put(
632               MANIFEST_ICON_URL_KEY,
633               "https://d3lwq5rlu14cro.cloudfront.net/ExponentEmptyManifest_192.png"
634             )
635           }
636           if (!json.has(MANIFEST_ORIENTATION_KEY)) {
637             json.put(MANIFEST_ORIENTATION_KEY, "default")
638           }
639         }
640       })
641     }
642   }
643 
644   init {
645     val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
646     // Use 1/16th of the available memory for this memory cache.
647     val cacheSize = maxMemory / 16
648     memoryCache = object : LruCache<String, Bitmap>(cacheSize) {
649       override fun sizeOf(key: String?, bitmap: Bitmap): Int {
650         return bitmap.byteCount / 1024
651       }
652     }
653   }
654 }
655