1 // Copyright 2015-present 650 Industries. All rights reserved.
2 package host.exp.expoview
3 
4 import android.app.Activity
5 import android.app.Application
6 import android.content.Context
7 import android.content.Intent
8 import android.net.Uri
9 import android.os.Handler
10 import android.os.Looper
11 import android.os.StrictMode
12 import android.os.StrictMode.ThreadPolicy
13 import android.os.UserManager
14 import com.facebook.common.internal.ByteStreams
15 import com.facebook.drawee.backends.pipeline.Fresco
16 import com.facebook.stetho.Stetho
17 import com.raizlabs.android.dbflow.config.DatabaseConfig
18 import com.raizlabs.android.dbflow.config.FlowConfig
19 import com.raizlabs.android.dbflow.config.FlowManager
20 import expo.modules.core.interfaces.Package
21 import expo.modules.core.interfaces.SingletonModule
22 import expo.modules.updates.manifest.raw.RawManifest
23 import host.exp.exponent.*
24 import host.exp.exponent.ExponentManifest.ManifestListener
25 import host.exp.exponent.analytics.Analytics
26 import host.exp.exponent.analytics.EXL
27 import host.exp.exponent.di.NativeModuleDepsProvider
28 import host.exp.exponent.kernel.ExponentUrls
29 import host.exp.exponent.kernel.KernelConstants
30 import host.exp.exponent.network.ExpoHttpCallback
31 import host.exp.exponent.network.ExpoResponse
32 import host.exp.exponent.network.ExponentHttpClient.SafeCallback
33 import host.exp.exponent.network.ExponentNetwork
34 import host.exp.exponent.notifications.ActionDatabase
35 import host.exp.exponent.notifications.managers.SchedulersDatabase
36 import host.exp.exponent.storage.ExponentDB
37 import host.exp.exponent.storage.ExponentSharedPreferences
38 import okhttp3.*
39 import org.apache.commons.io.IOUtils
40 import org.apache.commons.io.output.ByteArrayOutputStream
41 import org.apache.commons.io.output.TeeOutputStream
42 import org.json.JSONArray
43 import org.json.JSONException
44 import versioned.host.exp.exponent.ExponentPackageDelegate
45 import java.io.*
46 import java.net.URLEncoder
47 import java.util.concurrent.CopyOnWriteArrayList
48 import javax.inject.Inject
49 
50 class Exponent private constructor(val context: Context, val application: Application) {
51   var currentActivity: Activity? = null
52 
53   private val bundleStrings = mutableMapOf<String, String>()
54 
55   fun getBundleSource(path: String): String? {
56     synchronized(bundleStrings) {
57       return bundleStrings.remove(path)
58     }
59   }
60 
61   @Inject
62   lateinit var exponentNetwork: ExponentNetwork
63 
64   @Inject
65   lateinit var exponentManifest: ExponentManifest
66 
67   @Inject
68   lateinit var exponentSharedPreferences: ExponentSharedPreferences
69 
70   @Inject
71   lateinit var expoHandler: ExpoHandler
72 
73   fun runOnUiThread(action: Runnable) {
74     if (Thread.currentThread() !== Looper.getMainLooper().thread) {
75       Handler(context.mainLooper).post(action)
76     } else {
77       action.run()
78     }
79   }
80 
81   private val activityResultListeners = CopyOnWriteArrayList<ActivityResultListener>()
82 
83   data class InstanceManagerBuilderProperties(
84     var application: Application?,
85     var jsBundlePath: String?,
86     var experienceProperties: Map<String, Any?>,
87     var expoPackages: List<Package>?,
88     var exponentPackageDelegate: ExponentPackageDelegate?,
89     var manifest: RawManifest,
90     var singletonModules: List<SingletonModule>,
91   )
92 
93   fun addActivityResultListener(listener: ActivityResultListener) {
94     activityResultListeners.add(listener)
95   }
96 
97   fun removeActivityResultListener(listener: ActivityResultListener) {
98     activityResultListeners.remove(listener)
99   }
100 
101   fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
102     for (listener in activityResultListeners) {
103       listener.onActivityResult(requestCode, resultCode, data)
104     }
105   }
106 
107   /*
108    * Bundle loading
109    */
110   interface BundleListener {
111     fun onBundleLoaded(localBundlePath: String)
112     fun onError(e: Exception)
113   }
114 
115   // `id` must be URL encoded. Returns true if found cached bundle.
116   @JvmOverloads
117   fun loadJSBundle(
118     manifest: RawManifest?,
119     urlString: String,
120     id: String,
121     abiVersion: String,
122     bundleListener: BundleListener,
123     shouldForceNetworkArg: Boolean = false,
124     shouldForceCache: Boolean = false
125   ): Boolean {
126     var shouldForceNetwork = shouldForceNetworkArg
127     if (id != KernelConstants.KERNEL_BUNDLE_ID) {
128       Analytics.markEvent(Analytics.TimedEvent.STARTED_FETCHING_BUNDLE)
129     }
130     val isDeveloping = manifest?.isDevelopmentMode() ?: false
131     if (isDeveloping) {
132       // This is important for running locally with no-dev
133       shouldForceNetwork = true
134     }
135 
136     // The bundle is cached in two places:
137     //   1. The OkHttp cache (which lives in internal storage)
138     //   2. Written to our own file (in cache dir)
139     // Ideally we'd take the OkHttp response and send the InputStream directly to RN but RN doesn't
140     // support that right now so we need to write the response to a file.
141     // getCacheDir() doesn't work here! Some phones clean the file up in between when we check
142     // file.exists() and when we feed it into React Native!
143     // TODO: clean up files here!
144     val fileName =
145       KernelConstants.BUNDLE_FILE_PREFIX + id + urlString.hashCode().toString() + '-' + abiVersion
146     val directory = File(context.filesDir, abiVersion)
147     if (!directory.exists()) {
148       directory.mkdir()
149     }
150 
151     try {
152       val requestBuilder = if (KernelConstants.KERNEL_BUNDLE_ID == id) {
153         // TODO(eric): remove once home bundle is loaded normally
154         ExponentUrls.addExponentHeadersToUrl(urlString)
155       } else {
156         Request.Builder().url(urlString)
157       }
158       if (shouldForceNetwork) {
159         requestBuilder.cacheControl(CacheControl.FORCE_NETWORK)
160       }
161       val request = requestBuilder.build()
162 
163       // Use OkHttpClient with long read timeout for dev bundles
164       val callback: SafeCallback = object : SafeCallback {
165         override fun onFailure(e: IOException) {
166           bundleListener.onError(e)
167         }
168 
169         override fun onResponse(response: ExpoResponse) {
170           if (!response.isSuccessful) {
171             var body = "(could not render body)"
172             try {
173               body = response.body().string()
174             } catch (e: IOException) {
175               EXL.e(TAG, e)
176             }
177             bundleListener.onError(
178               Exception(
179                 "Bundle return code: " + response.code() +
180                   ". With body: " + body
181               )
182             )
183             return
184           }
185 
186           if (id != KernelConstants.KERNEL_BUNDLE_ID) {
187             Analytics.markEvent(Analytics.TimedEvent.FINISHED_FETCHING_BUNDLE)
188           }
189 
190           try {
191             if (id != KernelConstants.KERNEL_BUNDLE_ID) {
192               Analytics.markEvent(Analytics.TimedEvent.STARTED_WRITING_BUNDLE)
193             }
194 
195             val sourceFile = File(directory, fileName)
196 
197             var hasCachedSourceFile = false
198             val networkResponse = response.networkResponse()
199             if (networkResponse == null || networkResponse.code() == KernelConstants.HTTP_NOT_MODIFIED) {
200               // If we're getting a cached response don't rewrite the file to disk.
201               EXL.d(TAG, "Got cached OkHttp response for $urlString")
202               if (sourceFile.exists()) {
203                 hasCachedSourceFile = true
204                 EXL.d(TAG, "Have cached source file for $urlString")
205               }
206             }
207 
208             if (!hasCachedSourceFile) {
209               var inputStream: InputStream? = null
210               var fileOutputStream: FileOutputStream? = null
211               var byteArrayOutputStream: ByteArrayOutputStream? = null
212               var teeOutputStream: TeeOutputStream? = null
213               try {
214                 EXL.d(TAG, "Do not have cached source file for $urlString")
215                 inputStream = response.body().byteStream()
216                 fileOutputStream = FileOutputStream(sourceFile)
217                 byteArrayOutputStream = ByteArrayOutputStream()
218 
219                 // Multiplex the stream. Write both to file and string.
220                 teeOutputStream = TeeOutputStream(fileOutputStream, byteArrayOutputStream)
221                 ByteStreams.copy(inputStream, teeOutputStream)
222                 teeOutputStream.flush()
223                 bundleStrings[sourceFile.absolutePath] = byteArrayOutputStream.toString()
224                 fileOutputStream.flush()
225                 fileOutputStream.fd.sync()
226               } finally {
227                 IOUtils.closeQuietly(teeOutputStream)
228                 IOUtils.closeQuietly(fileOutputStream)
229                 IOUtils.closeQuietly(byteArrayOutputStream)
230                 IOUtils.closeQuietly(inputStream)
231               }
232             }
233 
234             if (id != KernelConstants.KERNEL_BUNDLE_ID) {
235               Analytics.markEvent(Analytics.TimedEvent.FINISHED_WRITING_BUNDLE)
236             }
237 
238             if (Constants.WRITE_BUNDLE_TO_LOG) {
239               printSourceFile(sourceFile.absolutePath)
240             }
241 
242             expoHandler.post { bundleListener.onBundleLoaded(sourceFile.absolutePath) }
243           } catch (e: Exception) {
244             bundleListener.onError(e)
245           }
246         }
247 
248         override fun onCachedResponse(response: ExpoResponse, isEmbedded: Boolean) {
249           EXL.d(TAG, "Using cached or embedded response.")
250           onResponse(response)
251         }
252       }
253 
254       exponentNetwork.longTimeoutClient.apply {
255         when {
256           shouldForceCache -> tryForcedCachedResponse(
257             request.url().toString(),
258             request,
259             callback,
260             null,
261             null
262           )
263           shouldForceNetwork -> callSafe(request, callback)
264           else -> callDefaultCache(request, callback)
265         }
266       }
267     } catch (e: Exception) {
268       bundleListener.onError(e)
269     }
270 
271     // Guess whether we'll use the cache based on whether the source file is saved.
272     val sourceFile = File(directory, fileName)
273     return sourceFile.exists()
274   }
275 
276   private fun printSourceFile(path: String) {
277     EXL.d(KernelConstants.BUNDLE_TAG, "Printing bundle:")
278     val inputStream = try {
279       FileInputStream(path)
280     } catch (e: Exception) {
281       EXL.e(KernelConstants.BUNDLE_TAG, e.toString())
282       return
283     }
284     inputStream.bufferedReader().useLines { lines ->
285       lines.forEach { line -> EXL.d(KernelConstants.BUNDLE_TAG, line) }
286     }
287   }
288 
289   interface PackagerStatusCallback {
290     fun onSuccess()
291     fun onFailure(errorMessage: String)
292   }
293 
294   fun testPackagerStatus(
295     isDebug: Boolean,
296     mManifest: RawManifest,
297     callback: PackagerStatusCallback
298   ) {
299     if (!isDebug) {
300       callback.onSuccess()
301       return
302     }
303 
304     val debuggerHost = mManifest.getDebuggerHost()
305     exponentNetwork.noCacheClient.newCall(
306       Request.Builder().url("http://$debuggerHost/status").build()
307     ).enqueue(object : Callback {
308       override fun onFailure(call: Call, e: IOException) {
309         EXL.d(TAG, e.toString())
310         callback.onFailure("Packager is not running at http://$debuggerHost")
311       }
312 
313       @Throws(IOException::class)
314       override fun onResponse(call: Call, response: Response) {
315         val responseString = response.body()!!.string()
316         if (responseString.contains(PACKAGER_RUNNING)) {
317           runOnUiThread { callback.onSuccess() }
318         } else {
319           callback.onFailure("Packager is not running at http://$debuggerHost")
320         }
321       }
322     })
323   }
324 
325   interface StartReactInstanceDelegate {
326     val isDebugModeEnabled: Boolean
327     val isInForeground: Boolean
328     val exponentPackageDelegate: ExponentPackageDelegate?
329     fun handleUnreadNotifications(unreadNotifications: JSONArray)
330   }
331 
332   fun preloadManifestAndBundle(manifestUrl: String) {
333     try {
334       exponentManifest.fetchManifest(
335         manifestUrl,
336         object : ManifestListener {
337           override fun onCompleted(manifest: RawManifest) {
338             try {
339               val bundleUrl = manifest.getBundleURL()
340               preloadBundle(
341                 manifest,
342                 manifestUrl,
343                 bundleUrl,
344                 manifest.getLegacyID(),
345                 manifest.getSDKVersion()
346               )
347             } catch (e: JSONException) {
348               EXL.e(TAG, e)
349             } catch (e: Exception) {
350               // Don't let any errors through
351               EXL.e(TAG, "Couldn't preload bundle: $e")
352             }
353           }
354 
355           override fun onError(e: Exception) {
356             EXL.e(TAG, "Couldn't preload manifest: $e")
357           }
358 
359           override fun onError(e: String) {
360             EXL.e(TAG, "Couldn't preload manifest: $e")
361           }
362         }
363       )
364     } catch (e: Throwable) {
365       EXL.e(TAG, "Couldn't preload manifest: $e")
366     }
367   }
368 
369   private fun preloadBundle(
370     manifest: RawManifest,
371     manifestUrl: String,
372     bundleUrl: String,
373     id: String,
374     sdkVersion: String
375   ) {
376     try {
377       instance.loadJSBundle(
378         manifest,
379         bundleUrl,
380         encodeExperienceId(id),
381         sdkVersion,
382         object : BundleListener {
383           override fun onError(e: Exception) {
384             EXL.e(TAG, "Couldn't preload bundle: $e")
385           }
386 
387           override fun onBundleLoaded(localBundlePath: String) {
388             EXL.d(TAG, "Successfully preloaded manifest and bundle for $manifestUrl $bundleUrl")
389           }
390         },
391         true
392       )
393     } catch (e: UnsupportedEncodingException) {
394       EXL.e(TAG, "Couldn't encode preloaded bundle id: $e")
395     }
396   }
397 
398   companion object {
399     private val TAG = Exponent::class.java.simpleName
400 
401     private const val PACKAGER_RUNNING = "running"
402 
403     @JvmStatic lateinit var instance: Exponent
404       private set
405     private var hasBeenInitialized = false
406 
407     @JvmStatic fun initialize(context: Context, application: Application) {
408       if (!hasBeenInitialized) {
409         hasBeenInitialized = true
410         Exponent(context, application)
411       }
412     }
413 
414     @Throws(UnsupportedEncodingException::class)
415     fun encodeExperienceId(manifestId: String): String {
416       return URLEncoder.encode("experience-$manifestId", "UTF-8")
417     }
418 
419     fun getPort(urlArg: String): Int {
420       var url = urlArg
421       if (!url.contains("://")) {
422         url = "http://$url"
423       }
424       val uri = Uri.parse(url)
425       val port = uri.port
426       return if (port == -1) {
427         80
428       } else {
429         port
430       }
431     }
432 
433     fun getHostname(urlArg: String): String? {
434       var url = urlArg
435       if (!url.contains("://")) {
436         url = "http://$url"
437       }
438       val uri = Uri.parse(url)
439       return uri.host
440     }
441 
442     @JvmStatic fun enableDeveloperSupport(
443       debuggerHost: String,
444       mainModuleName: String,
445       builder: RNObject
446     ) {
447       if (debuggerHost.isEmpty() || mainModuleName.isEmpty()) {
448         return
449       }
450 
451       try {
452         val fieldObject = RNObject("com.facebook.react.modules.systeminfo.AndroidInfoHelpers")
453         fieldObject.loadVersion(builder.version())
454 
455         val debuggerHostHostname = getHostname(debuggerHost)
456         val debuggerHostPort = getPort(debuggerHost)
457 
458         val deviceField = fieldObject.rnClass()!!.getDeclaredField("DEVICE_LOCALHOST")
459         deviceField.isAccessible = true
460         deviceField[null] = debuggerHostHostname
461 
462         val genymotionField = fieldObject.rnClass()!!.getDeclaredField("GENYMOTION_LOCALHOST")
463         genymotionField.isAccessible = true
464         genymotionField[null] = debuggerHostHostname
465 
466         val emulatorField = fieldObject.rnClass()!!.getDeclaredField("EMULATOR_LOCALHOST")
467         emulatorField.isAccessible = true
468         emulatorField[null] = debuggerHostHostname
469 
470         fieldObject.callStatic("setDevServerPort", debuggerHostPort)
471         fieldObject.callStatic("setInspectorProxyPort", debuggerHostPort)
472 
473         builder.callRecursive("setUseDeveloperSupport", true)
474         builder.callRecursive("setJSMainModulePath", mainModuleName)
475       } catch (e: IllegalAccessException) {
476         e.printStackTrace()
477       } catch (e: NoSuchFieldException) {
478         e.printStackTrace()
479       }
480     }
481   }
482 
483   init {
484     instance = this
485     NativeModuleDepsProvider.initialize(application)
486     NativeModuleDepsProvider.instance.inject(Exponent::class.java, this)
487 
488     // Verifying SSL certs is slow on Android, so send an HTTPS request to our server as early as possible.
489     // This speeds up the manifest request in a shell app from ~500ms to ~250ms.
490     try {
491       exponentNetwork.client.call(
492         Request.Builder().url(Constants.API_HOST + "/status").build(),
493         object : ExpoHttpCallback {
494           override fun onFailure(e: IOException) {
495             EXL.d(TAG, e.toString())
496           }
497 
498           @Throws(IOException::class)
499           override fun onResponse(response: ExpoResponse) {
500             ExponentNetwork.flushResponse(response)
501             EXL.d(TAG, "Loaded exp.host status page.")
502           }
503         }
504       )
505     } catch (e: Throwable) {
506       EXL.e(TAG, e)
507     }
508 
509     // Fixes Android memory leak
510     try {
511       UserManager::class.java.getMethod("get", Context::class.java).invoke(null, context)
512     } catch (e: Throwable) {
513       EXL.testError(e)
514     }
515 
516     try {
517       Fresco.initialize(context)
518     } catch (e: RuntimeException) {
519       EXL.testError(e)
520     }
521 
522     // Amplitude
523     Analytics.initializeAmplitude(context, application)
524 
525     // TODO: profile this
526     FlowManager.init(
527       FlowConfig.builder(context)
528         .addDatabaseConfig(
529           DatabaseConfig.builder(SchedulersDatabase::class.java)
530             .databaseName(SchedulersDatabase.NAME)
531             .build()
532         )
533         .addDatabaseConfig(
534           DatabaseConfig.builder(ActionDatabase::class.java)
535             .databaseName(ActionDatabase.NAME)
536             .build()
537         )
538         .addDatabaseConfig(
539           DatabaseConfig.builder(ExponentDB::class.java)
540             .databaseName(ExponentDB.NAME)
541             .build()
542         )
543         .build()
544     )
545 
546     if (ExpoViewBuildConfig.DEBUG) {
547       Stetho.initializeWithDefaults(context)
548     }
549 
550     if (!ExpoViewBuildConfig.DEBUG) {
551       // There are a few places in RN code that throw NetworkOnMainThreadException.
552       // WebsocketJavaScriptExecutor.connectInternal closes a websocket on the main thread.
553       // Shouldn't actually block the ui since it's fire and forget so not high priority to fix the root cause.
554       val policy = ThreadPolicy.Builder().permitAll().build()
555       StrictMode.setThreadPolicy(policy)
556     }
557   }
558 }
559