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