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.text.TextUtils
11 import android.util.LruCache
12 import expo.modules.manifests.core.InternalJSONMutator
13 import expo.modules.manifests.core.Manifest
14 import host.exp.exponent.analytics.EXL
15 import host.exp.exponent.generated.ExponentBuildConstants
16 import host.exp.exponent.kernel.ExponentUrls
17 import host.exp.exponent.kernel.KernelProvider
18 import host.exp.exponent.storage.ExponentSharedPreferences
19 import host.exp.exponent.utils.ColorParser
20 import host.exp.expoview.R
21 import org.apache.commons.io.IOUtils
22 import org.json.JSONException
23 import org.json.JSONObject
24 import java.io.IOException
25 import java.net.HttpURLConnection
26 import java.net.URL
27 import javax.inject.Inject
28 import javax.inject.Singleton
29 import kotlin.math.max
30 
31 @Singleton
32 class ExponentManifest @Inject constructor(
33   var context: Context,
34   var exponentSharedPreferences: ExponentSharedPreferences
35 ) {
36   interface BitmapListener {
37     fun onLoadBitmap(bitmap: Bitmap?)
38   }
39 
40   private val memoryCache: LruCache<String, Bitmap>
41 
42   fun httpManifestUrl(manifestUrl: String): Uri {
43     return httpManifestUrlBuilder(manifestUrl).build()
44   }
45 
46   private fun httpManifestUrlBuilder(manifestUrl: String): Uri.Builder {
47     var realManifestUrl = manifestUrl
48     if (manifestUrl.contains(REDIRECT_SNIPPET)) {
49       // Redirect urls look like "https://exp.host/--/to-exp/exp%3A%2F%2Fgj-5x6.jesse.internal.exp.direct%3A80".
50       // Android is crazy and catches this url with this intent filter:
51       //  <data
52       //    android:host="*.exp.direct"
53       //    android:pathPattern=".*"
54       //    android:scheme="http"/>
55       //  <data
56       //    android:host="*.exp.direct"
57       //    android:pathPattern=".*"
58       //    android:scheme="https"/>
59       // so we have to add some special logic to handle that. This is than handling arbitrary HTTP 301s and 302
60       realManifestUrl = Uri.decode(
61         realManifestUrl.substring(
62           realManifestUrl.indexOf(
63             REDIRECT_SNIPPET
64           ) + REDIRECT_SNIPPET.length
65         )
66       )
67     }
68     val httpManifestUrl = ExponentUrls.toHttp(realManifestUrl)
69     val uri = Uri.parse(httpManifestUrl)
70     var newPath = uri.path
71     if (newPath == null) {
72       newPath = ""
73     }
74     val deepLinkIndex = newPath.indexOf(DEEP_LINK_SEPARATOR_WITH_SLASH)
75     if (deepLinkIndex > -1) {
76       newPath = newPath.substring(0, deepLinkIndex)
77     }
78     return uri.buildUpon().encodedPath(newPath)
79   }
80 
81   fun loadIconBitmap(iconUrl: String?, listener: BitmapListener) {
82     val icon = getIconFromCache(iconUrl)
83     if (icon != null) {
84       listener.onLoadBitmap(icon)
85       return
86     }
87     object : AsyncTask<Void?, Void?, Bitmap>() {
88       override fun doInBackground(vararg p0: Void?): Bitmap? {
89         return loadIconTask(iconUrl)
90       }
91 
92       override fun onPostExecute(result: Bitmap?) {
93         listener.onLoadBitmap(result)
94       }
95     }.execute()
96   }
97 
98   private fun getIconFromCache(iconUrl: String?): Bitmap? {
99     return if (iconUrl == null || TextUtils.isEmpty(iconUrl)) {
100       BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher)
101     } else memoryCache[iconUrl]
102   }
103 
104   private fun loadIconTask(iconUrl: String?): Bitmap? {
105     return try {
106       // TODO: inject shared OkHttp client
107       val url = URL(iconUrl)
108       val connection = url.openConnection() as HttpURLConnection
109       connection.doInput = true
110       connection.connect()
111       val input = connection.inputStream
112       val bitmap = BitmapFactory.decodeStream(input)
113       val width = bitmap.width
114       val height = bitmap.height
115       if (width <= MAX_BITMAP_SIZE && height <= MAX_BITMAP_SIZE) {
116         memoryCache.put(iconUrl, bitmap)
117         return bitmap
118       }
119       val maxDimension = max(width, height)
120       val scaledWidth = width.toFloat() * MAX_BITMAP_SIZE / maxDimension
121       val scaledHeight = height.toFloat() * MAX_BITMAP_SIZE / maxDimension
122       val scaledBitmap =
123         Bitmap.createScaledBitmap(bitmap, scaledWidth.toInt(), scaledHeight.toInt(), true)
124       memoryCache.put(iconUrl, scaledBitmap)
125       scaledBitmap
126     } catch (e: IOException) {
127       EXL.e(TAG, e)
128       BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher)
129     } catch (e: Throwable) {
130       EXL.e(TAG, e)
131       BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher)
132     }
133   }
134 
135   fun getColorFromManifest(manifest: Manifest): Int {
136     val colorString = manifest.getPrimaryColor()
137     return if (colorString != null && ColorParser.isValid(colorString)) {
138       Color.parseColor(colorString)
139     } else {
140       R.color.colorPrimary
141     }
142   }
143 
144   fun isAnonymousExperience(manifest: Manifest): Boolean {
145     return try {
146       manifest.getScopeKey().startsWith(ANONYMOUS_SCOPE_KEY_PREFIX)
147     } catch (e: JSONException) {
148       false
149     }
150   }
151 
152   private fun getLocalKernelManifest(): Manifest = try {
153     val manifest = JSONObject(ExponentBuildConstants.BUILD_MACHINE_KERNEL_MANIFEST)
154     manifest.put(MANIFEST_IS_VERIFIED_KEY, true)
155     Manifest.fromManifestJson(manifest)
156   } catch (e: JSONException) {
157     throw RuntimeException("Can't get local manifest: $e")
158   }
159 
160   private fun getRemoteKernelManifest(): Manifest? = try {
161     val inputStream = context.assets.open(EMBEDDED_KERNEL_MANIFEST_ASSET)
162     val jsonString = IOUtils.toString(inputStream)
163     val manifest = JSONObject(jsonString)
164     manifest.put(MANIFEST_IS_VERIFIED_KEY, true)
165     Manifest.fromManifestJson(manifest)
166   } catch (e: Exception) {
167     KernelProvider.instance.handleError(e)
168     null
169   }
170 
171   fun getKernelManifest(): Manifest {
172     val manifest: Manifest?
173     val log: String
174     if (exponentSharedPreferences.shouldUseInternetKernel()) {
175       log = "Using remote Expo kernel manifest"
176       manifest = getRemoteKernelManifest()
177     } else {
178       log = "Using local Expo kernel manifest"
179       manifest = getLocalKernelManifest()
180     }
181     if (!hasShownKernelManifestLog) {
182       hasShownKernelManifestLog = true
183       EXL.d(TAG, log + ": " + manifest.toString())
184     }
185     return manifest!!
186   }
187 
188   companion object {
189     private val TAG = ExponentManifest::class.java.simpleName
190 
191     const val MANIFEST_STRING_KEY = "manifestString"
192     const val MANIFEST_SIGNATURE_KEY = "signature"
193     const val MANIFEST_ID_KEY = "id"
194     const val MANIFEST_NAME_KEY = "name"
195     const val MANIFEST_APP_KEY_KEY = "appKey"
196     const val MANIFEST_SDK_VERSION_KEY = "sdkVersion"
197     const val MANIFEST_IS_VERIFIED_KEY = "isVerified"
198     const val MANIFEST_ICON_URL_KEY = "iconUrl"
199     const val MANIFEST_BACKGROUND_COLOR_KEY = "backgroundColor"
200     const val MANIFEST_PRIMARY_COLOR_KEY = "primaryColor"
201     const val MANIFEST_ORIENTATION_KEY = "orientation"
202     const val MANIFEST_DEVELOPER_KEY = "developer"
203     const val MANIFEST_DEVELOPER_TOOL_KEY = "tool"
204     const val MANIFEST_PACKAGER_OPTS_KEY = "packagerOpts"
205     const val MANIFEST_PACKAGER_OPTS_DEV_KEY = "dev"
206     const val MANIFEST_BUNDLE_URL_KEY = "bundleUrl"
207     const val MANIFEST_REVISION_ID_KEY = "revisionId"
208     const val MANIFEST_PUBLISHED_TIME_KEY = "publishedTime"
209     const val MANIFEST_COMMIT_TIME_KEY = "commitTime"
210     const val MANIFEST_LOADED_FROM_CACHE_KEY = "loadedFromCache"
211     const val MANIFEST_SLUG = "slug"
212     const val MANIFEST_ANDROID_INFO_KEY = "android"
213     const val MANIFEST_KEYBOARD_LAYOUT_MODE_KEY = "softwareKeyboardLayoutMode"
214 
215     // Statusbar
216     const val MANIFEST_STATUS_BAR_KEY = "androidStatusBar"
217     const val MANIFEST_STATUS_BAR_APPEARANCE = "barStyle"
218     const val MANIFEST_STATUS_BAR_BACKGROUND_COLOR = "backgroundColor"
219     const val MANIFEST_STATUS_BAR_HIDDEN = "hidden"
220     const val MANIFEST_STATUS_BAR_TRANSLUCENT = "translucent"
221 
222     // NavigationBar
223     const val MANIFEST_NAVIGATION_BAR_KEY = "androidNavigationBar"
224     const val MANIFEST_NAVIGATION_BAR_VISIBLILITY = "visible"
225     const val MANIFEST_NAVIGATION_BAR_APPEARANCE = "barStyle"
226     const val MANIFEST_NAVIGATION_BAR_BACKGROUND_COLOR = "backgroundColor"
227 
228     // Notification
229     const val MANIFEST_NOTIFICATION_INFO_KEY = "notification"
230     const val MANIFEST_NOTIFICATION_ICON_URL_KEY = "iconUrl"
231     const val MANIFEST_NOTIFICATION_COLOR_KEY = "color"
232     const val MANIFEST_NOTIFICATION_ANDROID_MODE = "androidMode"
233     const val MANIFEST_NOTIFICATION_ANDROID_COLLAPSED_TITLE = "androidCollapsedTitle"
234 
235     // Debugging
236     const val MANIFEST_DEBUGGER_HOST_KEY = "debuggerHost"
237     const val MANIFEST_MAIN_MODULE_NAME_KEY = "mainModuleName"
238 
239     // Splash
240     const val MANIFEST_SPLASH_INFO_KEY = "splash"
241     const val MANIFEST_SPLASH_IMAGE_URL_KEY = "imageUrl"
242     const val MANIFEST_SPLASH_RESIZE_MODE_KEY = "resizeMode"
243     const val MANIFEST_SPLASH_BACKGROUND_COLOR_KEY = "backgroundColor"
244 
245     // Updates
246     const val MANIFEST_UPDATES_INFO_KEY = "updates"
247     const val MANIFEST_UPDATES_TIMEOUT_KEY = "fallbackToCacheTimeout"
248     const val MANIFEST_UPDATES_CHECK_AUTOMATICALLY_KEY = "checkAutomatically"
249     const val MANIFEST_UPDATES_CHECK_AUTOMATICALLY_ON_LOAD = "ON_LOAD"
250     const val MANIFEST_UPDATES_CHECK_AUTOMATICALLY_ON_ERROR = "ON_ERROR_RECOVERY"
251 
252     // Development client
253     const val MANIFEST_DEVELOPMENT_CLIENT_KEY = "developmentClient"
254     const val MANIFEST_DEVELOPMENT_CLIENT_SILENT_LAUNCH_KEY = "silentLaunch"
255     const val DEEP_LINK_SEPARATOR = "--"
256     const val DEEP_LINK_SEPARATOR_WITH_SLASH = "--/"
257     const val QUERY_PARAM_KEY_RELEASE_CHANNEL = "release-channel"
258     const val QUERY_PARAM_KEY_EXPO_UPDATES_RUNTIME_VERSION = "runtime-version"
259     const val QUERY_PARAM_KEY_EXPO_UPDATES_CHANNEL_NAME = "channel-name"
260 
261     private const val MAX_BITMAP_SIZE = 192
262     private const val REDIRECT_SNIPPET = "exp.host/--/to-exp/"
263     private const val ANONYMOUS_SCOPE_KEY_PREFIX = "@anonymous/"
264     private const val EMBEDDED_KERNEL_MANIFEST_ASSET = "kernel-manifest.json"
265     private const val EXPONENT_SERVER_HEADER = "Exponent-Server"
266 
267     private var hasShownKernelManifestLog = false
268 
269     @Throws(JSONException::class)
270     fun normalizeManifestInPlace(manifest: Manifest, manifestUrl: String) {
271       manifest.mutateInternalJSONInPlace(object : InternalJSONMutator {
272         override fun updateJSON(json: JSONObject) {
273           if (!json.has(MANIFEST_ID_KEY)) {
274             json.put(MANIFEST_ID_KEY, manifestUrl)
275           }
276           if (!json.has(MANIFEST_NAME_KEY)) {
277             json.put(MANIFEST_NAME_KEY, "My New Experience")
278           }
279           if (!json.has(MANIFEST_PRIMARY_COLOR_KEY)) {
280             json.put(MANIFEST_PRIMARY_COLOR_KEY, "#023C69")
281           }
282           if (!json.has(MANIFEST_ICON_URL_KEY)) {
283             json.put(
284               MANIFEST_ICON_URL_KEY,
285               "https://d3lwq5rlu14cro.cloudfront.net/ExponentEmptyManifest_192.png"
286             )
287           }
288           if (!json.has(MANIFEST_ORIENTATION_KEY)) {
289             json.put(MANIFEST_ORIENTATION_KEY, "default")
290           }
291         }
292       })
293     }
294   }
295 
296   init {
297     val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
298     // Use 1/16th of the available memory for this memory cache.
299     val cacheSize = maxMemory / 16
300     memoryCache = object : LruCache<String, Bitmap>(cacheSize) {
301       override fun sizeOf(key: String?, bitmap: Bitmap): Int {
302         return bitmap.byteCount / 1024
303       }
304     }
305   }
306 }
307