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