1 package expo.modules.updates
2 
3 import android.content.Context
4 import android.net.Uri
5 import android.os.AsyncTask
6 import android.os.Handler
7 import android.os.HandlerThread
8 import android.os.Looper
9 import android.util.Log
10 import com.facebook.react.ReactApplication
11 import com.facebook.react.ReactInstanceManager
12 import com.facebook.react.ReactNativeHost
13 import com.facebook.react.bridge.Arguments
14 import com.facebook.react.bridge.JSBundleLoader
15 import com.facebook.react.bridge.WritableMap
16 import expo.modules.updates.db.BuildData
17 import expo.modules.updates.db.DatabaseHolder
18 import expo.modules.updates.db.Reaper
19 import expo.modules.updates.db.UpdatesDatabase
20 import expo.modules.updates.db.entity.AssetEntity
21 import expo.modules.updates.db.entity.UpdateEntity
22 import expo.modules.updates.errorrecovery.ErrorRecovery
23 import expo.modules.updates.errorrecovery.ErrorRecoveryDelegate
24 import expo.modules.updates.launcher.DatabaseLauncher
25 import expo.modules.updates.launcher.Launcher
26 import expo.modules.updates.launcher.Launcher.LauncherCallback
27 import expo.modules.updates.launcher.NoDatabaseLauncher
28 import expo.modules.updates.loader.*
29 import expo.modules.updates.loader.LoaderTask.RemoteUpdateStatus
30 import expo.modules.updates.loader.LoaderTask.LoaderTaskCallback
31 import expo.modules.updates.logging.UpdatesErrorCode
32 import expo.modules.updates.logging.UpdatesLogReader
33 import expo.modules.updates.logging.UpdatesLogger
34 import expo.modules.updates.manifest.UpdateManifest
35 import expo.modules.updates.selectionpolicy.SelectionPolicy
36 import expo.modules.updates.selectionpolicy.SelectionPolicyFactory
37 import expo.modules.updates.statemachine.UpdatesStateChangeEventSender
38 import expo.modules.updates.statemachine.UpdatesStateContext
39 import expo.modules.updates.statemachine.UpdatesStateEvent
40 import expo.modules.updates.statemachine.UpdatesStateEventType
41 import expo.modules.updates.statemachine.UpdatesStateMachine
42 import expo.modules.updates.statemachine.UpdatesStateValue
43 import java.io.File
44 import java.lang.ref.WeakReference
45 
46 /**
47  * Main entry point to expo-updates in normal release builds (development clients, including Expo
48  * Go, use a different entry point). Singleton that keeps track of updates state, holds references
49  * to instances of other updates classes, and is the central hub for all updates-related tasks.
50  *
51  * The `start` method in this class should be invoked early in the application lifecycle, via
52  * [UpdatesPackage]. It delegates to an instance of [LoaderTask] to start the process of loading and
53  * launching an update, then responds appropriately depending on the callbacks that are invoked.
54  *
55  * This class also provides getter methods to access information about the updates state, which are
56  * used by the exported [UpdatesModule] through [UpdatesService]. Such information includes
57  * references to: the database, the [UpdatesConfiguration] object, the path on disk to the updates
58  * directory, any currently active [LoaderTask], the current [SelectionPolicy], the error recovery
59  * handler, and the current launched update. This class is intended to be the source of truth for
60  * these objects, so other classes shouldn't retain any of them indefinitely.
61  *
62  * This class also optionally holds a reference to the app's [ReactNativeHost], which allows
63  * expo-updates to reload JS and send events through the bridge.
64  */
65 class UpdatesController private constructor(
66   context: Context,
67   var updatesConfiguration: UpdatesConfiguration
68 ) : UpdatesStateChangeEventSender {
69   private var reactNativeHost: WeakReference<ReactNativeHost>? = if (context is ReactApplication) {
70     WeakReference((context as ReactApplication).reactNativeHost)
71   } else {
72     null
73   }
74 
75   var updatesDirectory: File? = null
76   var updatesDirectoryException: Exception? = null
77   var stateMachine: UpdatesStateMachine = UpdatesStateMachine(context, this)
78 
79   private var launcher: Launcher? = null
80   val databaseHolder = DatabaseHolder(UpdatesDatabase.getInstance(context))
81 
82   // TODO: move away from DatabaseHolder pattern to Handler thread
83   private val databaseHandlerThread = HandlerThread("expo-updates-database")
84   private lateinit var databaseHandler: Handler
initializeDatabaseHandlernull85   private fun initializeDatabaseHandler() {
86     if (!::databaseHandler.isInitialized) {
87       databaseHandlerThread.start()
88       databaseHandler = Handler(databaseHandlerThread.looper)
89     }
90   }
91 
purgeUpdatesLogsOlderThanOneDaynull92   private fun purgeUpdatesLogsOlderThanOneDay(context: Context) {
93     UpdatesLogReader(context).purgeLogEntries {
94       if (it != null) {
95         Log.e(TAG, "UpdatesLogReader: error in purgeLogEntries", it)
96       }
97     }
98   }
99 
100   private val logger = UpdatesLogger(context)
101   private var isStarted = false
102   private var loaderTask: LoaderTask? = null
103   private var remoteLoadStatus = ErrorRecoveryDelegate.RemoteLoadStatus.IDLE
104 
105   private var mSelectionPolicy: SelectionPolicy? = null
106   private var defaultSelectionPolicy: SelectionPolicy = SelectionPolicyFactory.createFilterAwarePolicy(
107     UpdatesUtils.getRuntimeVersion(updatesConfiguration)
108   )
109   val fileDownloader: FileDownloader = FileDownloader(context)
110   private val errorRecovery: ErrorRecovery = ErrorRecovery(context)
111 
setRemoteLoadStatusnull112   private fun setRemoteLoadStatus(status: ErrorRecoveryDelegate.RemoteLoadStatus) {
113     remoteLoadStatus = status
114     errorRecovery.notifyNewRemoteLoadStatus(status)
115   }
116 
117   // launch conditions
118   private var isLoaderTaskFinished = false
119   var isEmergencyLaunch = false
120     private set
121 
onDidCreateReactInstanceManagernull122   fun onDidCreateReactInstanceManager(reactInstanceManager: ReactInstanceManager) {
123     if (isEmergencyLaunch || !updatesConfiguration.isEnabled) {
124       return
125     }
126     errorRecovery.startMonitoring(reactInstanceManager)
127   }
128 
129   /**
130    * If UpdatesController.initialize() is not provided with a [ReactApplication], this method
131    * can be used to set a [ReactNativeHost] on the class. This is optional, but required in
132    * order for `Updates.reload()` and some Updates module events to work.
133    * @param reactNativeHost the ReactNativeHost of the application running the Updates module
134    */
setReactNativeHostnull135   fun setReactNativeHost(reactNativeHost: ReactNativeHost) {
136     this.reactNativeHost = WeakReference(reactNativeHost)
137   }
138 
139   /**
140    * Returns the path on disk to the launch asset (JS bundle) file for the React Native host to use.
141    * Blocks until the configured timeout runs out, or a new update has been downloaded and is ready
142    * to use (whichever comes sooner). ReactNativeHost.getJSBundleFile() should call into this.
143    *
144    * If this returns null, something has gone wrong and expo-updates has not been able to launch or
145    * find an update to use. In (and only in) this case, `getBundleAssetName()` will return a nonnull
146    * fallback value to use.
147    */
148   @get:Synchronized
149   val launchAssetFile: String?
150     get() {
151       while (!isLoaderTaskFinished) {
152         try {
153           (this as java.lang.Object).wait()
154         } catch (e: InterruptedException) {
155           Log.e(TAG, "Interrupted while waiting for launch asset file", e)
156         }
157       }
158       return launcher?.launchAssetFile
159     }
160 
161   /**
162    * Returns the filename of the launch asset (JS bundle) file embedded in the APK bundle, which can
163    * be read using `context.getAssets()`. This is only nonnull if `getLaunchAssetFile` is null and
164    * should only be used in such a situation. ReactNativeHost.getBundleAssetName() should call into
165    * this.
166    */
167   val bundleAssetName: String?
168     get() = launcher?.bundleAssetName
169 
170   /**
171    * Returns a map of the locally downloaded assets for the current update. Keys are the remote URLs
172    * of the assets and values are local paths. This should be exported by the Updates JS module and
173    * can be used by `expo-asset` or a similar module to override React Native's asset resolution and
174    * use the locally downloaded assets.
175    */
176   val localAssetFiles: Map<AssetEntity, String>?
177     get() = launcher?.localAssetFiles
178 
179   val isUsingEmbeddedAssets: Boolean
180     get() = launcher?.isUsingEmbeddedAssets ?: false
181 
182   /**
183    * Any process that calls this *must* manually release the lock by calling `releaseDatabase()` in
184    * every possible case (success, error) as soon as it is finished.
185    */
getDatabasenull186   fun getDatabase(): UpdatesDatabase = databaseHolder.database
187 
188   fun releaseDatabase() {
189     databaseHolder.releaseDatabase()
190   }
191 
192   val updateUrl: Uri?
193     get() = updatesConfiguration.updateUrl
194 
195   val launchedUpdate: UpdateEntity?
196     get() = launcher?.launchedUpdate
197 
198   val selectionPolicy: SelectionPolicy
199     get() = mSelectionPolicy ?: defaultSelectionPolicy
200 
201   // Internal Setters
202 
203   /**
204    * For external modules that want to modify the selection policy used at runtime.
205    *
206    * This method does not provide any guarantees about how long the provided selection policy will
207    * persist; sometimes expo-updates will reset the selection policy in situations where it makes
208    * sense to have explicit control (e.g. if the developer/user has programmatically fetched an
209    * update, expo-updates will reset the selection policy so the new update is launched on th
210    * next reload).
211    * @param selectionPolicy The SelectionPolicy to use next, until overridden by expo-updates
212    */
setNextSelectionPolicynull213   fun setNextSelectionPolicy(selectionPolicy: SelectionPolicy?) {
214     mSelectionPolicy = selectionPolicy
215   }
216 
resetSelectionPolicyToDefaultnull217   fun resetSelectionPolicyToDefault() {
218     mSelectionPolicy = null
219   }
220 
setDefaultSelectionPolicynull221   fun setDefaultSelectionPolicy(selectionPolicy: SelectionPolicy) {
222     defaultSelectionPolicy = selectionPolicy
223   }
224 
setLaunchernull225   fun setLauncher(launcher: Launcher?) {
226     this.launcher = launcher
227   }
228 
229   /**
230    * Starts the update process to launch a previously-loaded update and (if configured to do so)
231    * check for a new update from the server. This method should be called as early as possible in
232    * the application's lifecycle.
233    * @param context the base context of the application, ideally a [ReactApplication]
234    */
235   @Synchronized
startnull236   fun start(context: Context) {
237     if (isStarted) {
238       return
239     }
240     isStarted = true
241 
242     if (!updatesConfiguration.isEnabled) {
243       launcher = NoDatabaseLauncher(context, updatesConfiguration)
244       notifyController()
245       return
246     }
247     if (updatesConfiguration.updateUrl == null || updatesConfiguration.scopeKey == null) {
248       throw AssertionError("expo-updates is enabled, but no valid URL is configured in AndroidManifest.xml. If you are making a release build for the first time, make sure you have run `expo publish` at least once.")
249     }
250     if (updatesDirectory == null) {
251       launcher = NoDatabaseLauncher(context, updatesConfiguration, updatesDirectoryException)
252       isEmergencyLaunch = true
253       notifyController()
254       return
255     }
256 
257     purgeUpdatesLogsOlderThanOneDay(context)
258 
259     initializeDatabaseHandler()
260     initializeErrorRecovery(context)
261 
262     val databaseLocal = getDatabase()
263     BuildData.ensureBuildDataIsConsistent(updatesConfiguration, databaseLocal)
264     releaseDatabase()
265 
266     loaderTask = LoaderTask(
267       updatesConfiguration,
268       databaseHolder,
269       updatesDirectory,
270       fileDownloader,
271       selectionPolicy,
272       object : LoaderTaskCallback {
273         override fun onFailure(e: Exception) {
274           logger.error("UpdatesController loaderTask onFailure: ${e.localizedMessage}", UpdatesErrorCode.None)
275           launcher = NoDatabaseLauncher(context, updatesConfiguration, e)
276           isEmergencyLaunch = true
277           notifyController()
278         }
279 
280         override fun onCachedUpdateLoaded(update: UpdateEntity): Boolean {
281           return true
282         }
283 
284         override fun onRemoteCheckForUpdateStarted() {
285           stateMachine.processEvent(UpdatesStateEvent.Check())
286         }
287 
288         override fun onRemoteCheckForUpdateFinished(result: LoaderTask.RemoteCheckResult) {
289           val event = when (result) {
290             is LoaderTask.RemoteCheckResult.NoUpdateAvailable -> UpdatesStateEvent.CheckCompleteUnavailable()
291             is LoaderTask.RemoteCheckResult.UpdateAvailable -> UpdatesStateEvent.CheckCompleteWithUpdate(result.manifest)
292             is LoaderTask.RemoteCheckResult.RollBackToEmbedded -> UpdatesStateEvent.CheckCompleteWithRollback(result.commitTime)
293           }
294           stateMachine.processEvent(event)
295         }
296 
297         override fun onRemoteUpdateManifestResponseManifestLoaded(updateManifest: UpdateManifest) {
298           remoteLoadStatus = ErrorRecoveryDelegate.RemoteLoadStatus.NEW_UPDATE_LOADING
299         }
300 
301         override fun onSuccess(launcher: Launcher, isUpToDate: Boolean) {
302           if (remoteLoadStatus == ErrorRecoveryDelegate.RemoteLoadStatus.NEW_UPDATE_LOADING && isUpToDate) {
303             remoteLoadStatus = ErrorRecoveryDelegate.RemoteLoadStatus.IDLE
304           }
305           this@UpdatesController.launcher = launcher
306           notifyController()
307         }
308 
309         override fun onRemoteUpdateLoadStarted() {
310           stateMachine.processEvent(UpdatesStateEvent.Download())
311         }
312 
313         override fun onRemoteUpdateAssetLoaded(
314           asset: AssetEntity,
315           successfulAssetCount: Int,
316           failedAssetCount: Int,
317           totalAssetCount: Int
318         ) {
319           val body = mapOf(
320             "assetInfo" to mapOf(
321               "name" to asset.embeddedAssetFilename,
322               "successfulAssetCount" to successfulAssetCount,
323               "failedAssetCount" to failedAssetCount,
324               "totalAssetCount" to totalAssetCount
325             )
326           )
327           logger.info("AppController appLoaderTask didLoadAsset: $body", UpdatesErrorCode.None, null, asset.expectedHash)
328         }
329 
330         override fun onRemoteUpdateFinished(
331           status: RemoteUpdateStatus,
332           update: UpdateEntity?,
333           exception: Exception?
334         ) {
335           when (status) {
336             RemoteUpdateStatus.ERROR -> {
337               if (exception == null) {
338                 throw AssertionError("Background update with error status must have a nonnull exception object")
339               }
340               logger.error("UpdatesController onBackgroundUpdateFinished: Error: ${exception.localizedMessage}", UpdatesErrorCode.Unknown, exception)
341               remoteLoadStatus = ErrorRecoveryDelegate.RemoteLoadStatus.IDLE
342               val params = Arguments.createMap()
343               params.putString("message", exception.message)
344               sendLegacyUpdateEventToJS(UPDATE_ERROR_EVENT, params)
345 
346               // Since errors can happen through a number of paths, we do these checks
347               // to make sure the state machine is valid
348               when (stateMachine.state) {
349                 UpdatesStateValue.Idle -> {
350                   stateMachine.processEvent(UpdatesStateEvent.Download())
351                   stateMachine.processEvent(
352                     UpdatesStateEvent.DownloadError(exception.message ?: "")
353                   )
354                 }
355                 UpdatesStateValue.Checking -> {
356                   stateMachine.processEvent(
357                     UpdatesStateEvent.CheckError(exception.message ?: "")
358                   )
359                 }
360                 else -> {
361                   // .downloading
362                   stateMachine.processEvent(
363                     UpdatesStateEvent.DownloadError(exception.message ?: "")
364                   )
365                 }
366               }
367             }
368             RemoteUpdateStatus.UPDATE_AVAILABLE -> {
369               if (update == null) {
370                 throw AssertionError("Background update with error status must have a nonnull update object")
371               }
372               remoteLoadStatus = ErrorRecoveryDelegate.RemoteLoadStatus.NEW_UPDATE_LOADED
373               logger.info("UpdatesController onBackgroundUpdateFinished: Update available", UpdatesErrorCode.None)
374               val params = Arguments.createMap()
375               params.putString("manifestString", update.manifest.toString())
376               sendLegacyUpdateEventToJS(UPDATE_AVAILABLE_EVENT, params)
377               stateMachine.processEvent(
378                 UpdatesStateEvent.DownloadCompleteWithUpdate(update.manifest)
379               )
380             }
381             RemoteUpdateStatus.NO_UPDATE_AVAILABLE -> {
382               remoteLoadStatus = ErrorRecoveryDelegate.RemoteLoadStatus.IDLE
383               logger.error("UpdatesController onBackgroundUpdateFinished: No update available", UpdatesErrorCode.NoUpdatesAvailable)
384               sendLegacyUpdateEventToJS(UPDATE_NO_UPDATE_AVAILABLE_EVENT, null)
385               // TODO: handle rollbacks properly, but this works for now
386               if (stateMachine.state == UpdatesStateValue.Downloading) {
387                 stateMachine.processEvent(UpdatesStateEvent.DownloadComplete())
388               }
389             }
390           }
391           errorRecovery.notifyNewRemoteLoadStatus(remoteLoadStatus)
392         }
393       }
394     )
395     loaderTask!!.start(context)
396   }
397 
398   @Synchronized
notifyControllernull399   private fun notifyController() {
400     if (launcher == null) {
401       throw AssertionError("UpdatesController.notifyController was called with a null launcher, which is an error. This method should only be called when an update is ready to launch.")
402     }
403     isLoaderTaskFinished = true
404     (this as java.lang.Object).notify()
405   }
406 
initializeErrorRecoverynull407   private fun initializeErrorRecovery(context: Context) {
408     errorRecovery.initialize(object : ErrorRecoveryDelegate {
409       override fun loadRemoteUpdate() {
410         if (loaderTask?.isRunning == true) {
411           return
412         }
413         remoteLoadStatus = ErrorRecoveryDelegate.RemoteLoadStatus.NEW_UPDATE_LOADING
414         val database = getDatabase()
415         val remoteLoader = RemoteLoader(context, updatesConfiguration, database, fileDownloader, updatesDirectory, launchedUpdate)
416         remoteLoader.start(object : Loader.LoaderCallback {
417           override fun onFailure(e: Exception) {
418             logger.error("UpdatesController loadRemoteUpdate onFailure: ${e.localizedMessage}", UpdatesErrorCode.UpdateFailedToLoad, launchedUpdate?.loggingId, null)
419             setRemoteLoadStatus(ErrorRecoveryDelegate.RemoteLoadStatus.IDLE)
420             releaseDatabase()
421           }
422 
423           override fun onSuccess(loaderResult: Loader.LoaderResult) {
424             setRemoteLoadStatus(
425               if (loaderResult.updateEntity != null || loaderResult.updateDirective is UpdateDirective.RollBackToEmbeddedUpdateDirective) ErrorRecoveryDelegate.RemoteLoadStatus.NEW_UPDATE_LOADED
426               else ErrorRecoveryDelegate.RemoteLoadStatus.IDLE
427             )
428             releaseDatabase()
429           }
430 
431           override fun onAssetLoaded(asset: AssetEntity, successfulAssetCount: Int, failedAssetCount: Int, totalAssetCount: Int) { }
432 
433           override fun onUpdateResponseLoaded(updateResponse: UpdateResponse): Loader.OnUpdateResponseLoadedResult {
434             val updateDirective = updateResponse.directiveUpdateResponsePart?.updateDirective
435             if (updateDirective != null) {
436               return Loader.OnUpdateResponseLoadedResult(
437                 shouldDownloadManifestIfPresentInResponse = when (updateDirective) {
438                   is UpdateDirective.RollBackToEmbeddedUpdateDirective -> false
439                   is UpdateDirective.NoUpdateAvailableUpdateDirective -> false
440                 }
441               )
442             }
443 
444             val updateManifest = updateResponse.manifestUpdateResponsePart?.updateManifest ?: return Loader.OnUpdateResponseLoadedResult(shouldDownloadManifestIfPresentInResponse = false)
445             return Loader.OnUpdateResponseLoadedResult(shouldDownloadManifestIfPresentInResponse = selectionPolicy.shouldLoadNewUpdate(updateManifest.updateEntity, launchedUpdate, updateResponse.responseHeaderData?.manifestFilters))
446           }
447         })
448       }
449 
450       override fun relaunch(callback: LauncherCallback) { relaunchReactApplication(context, false, callback) }
451       override fun throwException(exception: Exception) { throw exception }
452 
453       override fun markFailedLaunchForLaunchedUpdate() {
454         if (isEmergencyLaunch) {
455           return
456         }
457         databaseHandler.post {
458           val launchedUpdate = launchedUpdate ?: return@post
459           val database = getDatabase()
460           database.updateDao().incrementFailedLaunchCount(launchedUpdate)
461           releaseDatabase()
462         }
463       }
464 
465       override fun markSuccessfulLaunchForLaunchedUpdate() {
466         if (isEmergencyLaunch) {
467           return
468         }
469         databaseHandler.post {
470           val launchedUpdate = launchedUpdate ?: return@post
471           val database = getDatabase()
472           database.updateDao().incrementSuccessfulLaunchCount(launchedUpdate)
473           releaseDatabase()
474         }
475       }
476 
477       override fun getRemoteLoadStatus() = remoteLoadStatus
478       override fun getCheckAutomaticallyConfiguration() = updatesConfiguration.checkOnLaunch
479       override fun getLaunchedUpdateSuccessfulLaunchCount() = launchedUpdate?.successfulLaunchCount ?: 0
480     })
481   }
482 
runReapernull483   fun runReaper() {
484     AsyncTask.execute {
485       val databaseLocal = getDatabase()
486       Reaper.reapUnusedUpdates(
487         updatesConfiguration,
488         databaseLocal,
489         updatesDirectory,
490         launchedUpdate,
491         selectionPolicy
492       )
493       releaseDatabase()
494     }
495   }
496 
relaunchReactApplicationnull497   fun relaunchReactApplication(context: Context, callback: LauncherCallback) {
498     relaunchReactApplication(context, true, callback)
499   }
500 
relaunchReactApplicationnull501   private fun relaunchReactApplication(context: Context, shouldRunReaper: Boolean, callback: LauncherCallback) {
502     val host = reactNativeHost?.get()
503     if (host == null) {
504       callback.onFailure(Exception("Could not reload application. Ensure you have passed the correct instance of ReactApplication into UpdatesController.initialize()."))
505       return
506     }
507 
508     stateMachine.processEvent(UpdatesStateEvent.Restart())
509 
510     val oldLaunchAssetFile = launcher!!.launchAssetFile
511 
512     val databaseLocal = getDatabase()
513     val newLauncher = DatabaseLauncher(
514       updatesConfiguration,
515       updatesDirectory!!,
516       fileDownloader,
517       selectionPolicy
518     )
519     newLauncher.launch(
520       databaseLocal, context,
521       object : LauncherCallback {
522         override fun onFailure(e: Exception) {
523           callback.onFailure(e)
524         }
525 
526         override fun onSuccess() {
527           launcher = newLauncher
528           releaseDatabase()
529 
530           val instanceManager = host.reactInstanceManager
531 
532           val newLaunchAssetFile = launcher!!.launchAssetFile
533           if (newLaunchAssetFile != null && newLaunchAssetFile != oldLaunchAssetFile) {
534             // Unfortunately, even though RN exposes a way to reload an application,
535             // it assumes that the JS bundle will stay at the same location throughout
536             // the entire lifecycle of the app. Since we need to change the location of
537             // the bundle, we need to use reflection to set an otherwise inaccessible
538             // field of the ReactInstanceManager.
539             try {
540               val newJSBundleLoader = JSBundleLoader.createFileLoader(newLaunchAssetFile)
541               val jsBundleLoaderField = instanceManager.javaClass.getDeclaredField("mBundleLoader")
542               jsBundleLoaderField.isAccessible = true
543               jsBundleLoaderField[instanceManager] = newJSBundleLoader
544             } catch (e: Exception) {
545               Log.e(TAG, "Could not reset JSBundleLoader in ReactInstanceManager", e)
546             }
547           }
548           callback.onSuccess()
549           val handler = Handler(Looper.getMainLooper())
550           handler.post { instanceManager.recreateReactContextInBackground() }
551           if (shouldRunReaper) {
552             runReaper()
553           }
554           stateMachine.reset()
555         }
556       }
557     )
558   }
559 
sendUpdateStateChangeEventToBridgenull560   override fun sendUpdateStateChangeEventToBridge(eventType: UpdatesStateEventType, context: UpdatesStateContext) {
561     sendEventToJS(UPDATES_STATE_CHANGE_EVENT_NAME, eventType.type, context.writableMap)
562   }
563 
sendLegacyUpdateEventToJSnull564   fun sendLegacyUpdateEventToJS(eventType: String, params: WritableMap?) {
565     sendEventToJS(UPDATES_EVENT_NAME, eventType, params)
566   }
567 
sendEventToJSnull568   private fun sendEventToJS(eventName: String, eventType: String, params: WritableMap?) {
569     UpdatesUtils.sendEventToReactNative(reactNativeHost, logger, eventName, eventType, params)
570   }
571 
572   companion object {
573     private val TAG = UpdatesController::class.java.simpleName
574 
575     private const val UPDATE_AVAILABLE_EVENT = "updateAvailable"
576     private const val UPDATE_NO_UPDATE_AVAILABLE_EVENT = "noUpdateAvailable"
577     private const val UPDATE_ERROR_EVENT = "error"
578 
579     private const val UPDATES_EVENT_NAME = "Expo.nativeUpdatesEvent"
580     private const val UPDATES_STATE_CHANGE_EVENT_NAME = "Expo.nativeUpdatesStateChangeEvent"
581 
582     private var singletonInstance: UpdatesController? = null
583     @JvmStatic val instance: UpdatesController
584       get() {
<lambda>null585         return checkNotNull(singletonInstance) { "UpdatesController.instance was called before the module was initialized" }
586       }
587 
initializeWithoutStartingnull588     @JvmStatic fun initializeWithoutStarting(context: Context) {
589       if (singletonInstance == null) {
590         val updatesConfiguration = UpdatesConfiguration(context, null)
591         singletonInstance = UpdatesController(context, updatesConfiguration)
592       }
593     }
594 
595     /**
596      * Initializes the UpdatesController singleton. This should be called as early as possible in the
597      * application's lifecycle.
598      * @param context the base context of the application, ideally a [ReactApplication]
599      */
initializenull600     @JvmStatic fun initialize(context: Context) {
601       if (singletonInstance == null) {
602         initializeWithoutStarting(context)
603         singletonInstance!!.start(context)
604       }
605     }
606 
607     /**
608      * Initializes the UpdatesController singleton. This should be called as early as possible in the
609      * application's lifecycle. Use this method to set or override configuration values at runtime
610      * rather than from AndroidManifest.xml.
611      * @param context the base context of the application, ideally a [ReactApplication]
612      */
initializenull613     @JvmStatic fun initialize(context: Context, configuration: Map<String, Any>) {
614       if (singletonInstance == null) {
615         val updatesConfiguration = UpdatesConfiguration(context, configuration)
616         singletonInstance = UpdatesController(context, updatesConfiguration)
617         singletonInstance!!.start(context)
618       }
619     }
620   }
621 
622   init {
623     try {
624       updatesDirectory = UpdatesUtils.getOrCreateUpdatesDirectory(context)
625     } catch (e: Exception) {
626       updatesDirectoryException = e
627       updatesDirectory = null
628     }
629   }
630 }
631