<lambda>null1 package expo.modules.updates.loader
2 
3 import android.content.Context
4 import android.os.AsyncTask
5 import android.os.Handler
6 import android.os.HandlerThread
7 import android.util.Log
8 import expo.modules.updates.UpdatesConfiguration
9 import expo.modules.updates.UpdatesUtils
10 import expo.modules.updates.db.DatabaseHolder
11 import expo.modules.updates.db.Reaper
12 import expo.modules.updates.db.entity.AssetEntity
13 import expo.modules.updates.db.entity.UpdateEntity
14 import expo.modules.updates.launcher.DatabaseLauncher
15 import expo.modules.updates.launcher.Launcher
16 import expo.modules.updates.launcher.Launcher.LauncherCallback
17 import expo.modules.updates.loader.Loader.LoaderCallback
18 import expo.modules.updates.manifest.EmbeddedManifest
19 import expo.modules.updates.manifest.ManifestMetadata
20 import expo.modules.updates.manifest.UpdateManifest
21 import expo.modules.updates.selectionpolicy.SelectionPolicy
22 import org.json.JSONObject
23 import java.io.File
24 import java.util.Date
25 
26 /**
27  * Controlling class that handles the complex logic that needs to happen each time the app is cold
28  * booted. From a high level, this class does the following:
29  *
30  * - Immediately starts an instance of [EmbeddedLoader] to load the embedded update into SQLite.
31  *   This does nothing if SQLite already has the embedded update or a newer one, but we have to do
32  *   this on each cold boot, as we have no way of knowing if a new build was just installed (which
33  *   could have a new embedded update).
34  * - If the app is configured for automatic update downloads (most apps), starts a timer based on
35  *   the `launchWaitMs` value in [UpdatesConfiguration].
36  * - Again if the app is configured for automatic update downloads, starts an instance of
37  *   [RemoteLoader] to check for and download a new update if there is one.
38  * - Once the download succeeds, fails, or the timer runs out (whichever happens first), creates an
39  *   instance of [DatabaseLauncher] and signals that the app is ready to be launched with the newest
40  *   update available locally at that time (which may not be the newest update if the download is
41  *   still in progress).
42  * - If the download succeeds or fails after this point, fires a callback which causes an event to
43  *   be sent to JS.
44  */
45 class LoaderTask(
46   private val configuration: UpdatesConfiguration,
47   private val databaseHolder: DatabaseHolder,
48   private val directory: File?,
49   private val fileDownloader: FileDownloader,
50   private val selectionPolicy: SelectionPolicy,
51   private val callback: LoaderTaskCallback
52 ) {
53   enum class RemoteUpdateStatus {
54     ERROR, NO_UPDATE_AVAILABLE, UPDATE_AVAILABLE
55   }
56 
57   enum class RemoteCheckResultNotAvailableReason(val value: String) {
58     /**
59      * No update manifest or rollback directive received from the update server.
60      */
61     NO_UPDATE_AVAILABLE_ON_SERVER("noUpdateAvailableOnServer"),
62     /**
63      * An update manifest was received from the update server, but the update is not launchable,
64      * or does not pass the configured selection policy.
65      */
66     UPDATE_REJECTED_BY_SELECTION_POLICY("updateRejectedBySelectionPolicy"),
67     /**
68      * An update manifest was received from the update server, but the update has been previously
69      * launched on this device and never successfully launched.
70      */
71     UPDATE_PREVIOUSLY_FAILED("updatePreviouslyFailed"),
72     /**
73      * A rollback directive was received from the update server, but the directive does not pass
74      * the configured selection policy.
75      */
76     ROLLBACK_REJECTED_BY_SELECTION_POLICY("rollbackRejectedBySelectionPolicy"),
77     /**
78      * A rollback directive was received from the update server, but this app has no embedded update.
79      */
80     ROLLBACK_NO_EMBEDDED("rollbackNoEmbeddedConfiguration"),
81   }
82 
83   sealed class RemoteCheckResult(private val status: Status) {
84     private enum class Status {
85       NO_UPDATE_AVAILABLE,
86       UPDATE_AVAILABLE,
87       ROLL_BACK_TO_EMBEDDED
88     }
89 
90     class NoUpdateAvailable(val reason: RemoteCheckResultNotAvailableReason) : RemoteCheckResult(Status.NO_UPDATE_AVAILABLE)
91     class UpdateAvailable(val manifest: JSONObject) : RemoteCheckResult(Status.UPDATE_AVAILABLE)
92     class RollBackToEmbedded(val commitTime: Date) : RemoteCheckResult(Status.ROLL_BACK_TO_EMBEDDED)
93   }
94 
95   interface LoaderTaskCallback {
96     fun onFailure(e: Exception)
97 
98     /**
99      * This method gives the calling class a backdoor option to ignore the cached update and force
100      * a remote load if it decides the cached update is not runnable. Returning false from this
101      * callback will force a remote load, overriding the timeout and configuration settings for
102      * whether or not to check for a remote update. Returning true from this callback will make
103      * LoaderTask proceed as usual.
104      */
105     fun onCachedUpdateLoaded(update: UpdateEntity): Boolean
106     fun onRemoteUpdateManifestResponseManifestLoaded(updateManifest: UpdateManifest)
107     fun onSuccess(launcher: Launcher, isUpToDate: Boolean)
108 
109     fun onRemoteCheckForUpdateStarted() {}
110     fun onRemoteCheckForUpdateFinished(result: RemoteCheckResult) {}
111     fun onRemoteUpdateLoadStarted() {}
112     fun onRemoteUpdateAssetLoaded(asset: AssetEntity, successfulAssetCount: Int, failedAssetCount: Int, totalAssetCount: Int) {}
113     fun onRemoteUpdateFinished(
114       status: RemoteUpdateStatus,
115       update: UpdateEntity?,
116       exception: Exception?
117     )
118   }
119 
120   private interface Callback {
121     fun onFailure(e: Exception)
122     fun onSuccess()
123   }
124 
125   var isRunning = false
126     private set
127 
128   // success conditions
129   private var isReadyToLaunch = false
130   private var timeoutFinished = false
131   private var hasLaunched = false
132   private var isUpToDate = false
133   private val handlerThread: HandlerThread = HandlerThread("expo-updates-timer")
134   private var candidateLauncher: Launcher? = null
135   private var finalizedLauncher: Launcher? = null
136 
137   fun start(context: Context) {
138     if (!configuration.isEnabled) {
139       callback.onFailure(Exception("LoaderTask was passed a configuration object with updates disabled. You should load updates from an embedded source rather than calling LoaderTask, or enable updates in the configuration."))
140       return
141     }
142 
143     if (configuration.updateUrl == null) {
144       callback.onFailure(Exception("LoaderTask was passed a configuration object with a null URL. You must pass a nonnull URL in order to use LoaderTask to load updates."))
145       return
146     }
147 
148     if (directory == null) {
149       throw AssertionError("LoaderTask directory must be nonnull.")
150     }
151 
152     isRunning = true
153 
154     val shouldCheckForUpdate = UpdatesUtils.shouldCheckForUpdateOnLaunch(configuration, context)
155     val delay = configuration.launchWaitMs
156     if (delay > 0 && shouldCheckForUpdate) {
157       handlerThread.start()
158       Handler(handlerThread.looper).postDelayed({ timeout() }, delay.toLong())
159     } else {
160       timeoutFinished = true
161     }
162 
163     launchFallbackUpdateFromDisk(
164       context,
165       object : Callback {
166         private fun launchRemoteUpdate() {
167           launchRemoteUpdateInBackground(
168             context,
169             object : Callback {
170               override fun onFailure(e: Exception) {
171                 finish(e)
172                 isRunning = false
173                 runReaper()
174               }
175 
176               override fun onSuccess() {
177                 synchronized(this@LoaderTask) { isReadyToLaunch = true }
178                 finish(null)
179                 isRunning = false
180                 runReaper()
181               }
182             }
183           )
184         }
185 
186         override fun onFailure(e: Exception) {
187           // An unexpected failure has occurred here, or we are running in an environment with no
188           // embedded update and we have no update downloaded (e.g. Expo client).
189           // What to do in this case depends on whether or not we're trying to load a remote update.
190           // If we are, then we should wait for the task to finish. If not, we need to fail here.
191           if (!shouldCheckForUpdate) {
192             finish(e)
193             isRunning = false
194           } else {
195             launchRemoteUpdate()
196           }
197           Log.e(TAG, "Failed to launch embedded or launchable update", e)
198         }
199 
200         override fun onSuccess() {
201           if (candidateLauncher!!.launchedUpdate != null &&
202             !callback.onCachedUpdateLoaded(candidateLauncher!!.launchedUpdate!!)
203           ) {
204             // ignore timer and other settings and force launch a remote update
205             stopTimer()
206             candidateLauncher = null
207             launchRemoteUpdate()
208           } else {
209             synchronized(this@LoaderTask) {
210               isReadyToLaunch = true
211               maybeFinish()
212             }
213             if (shouldCheckForUpdate) {
214               launchRemoteUpdate()
215             } else {
216               isRunning = false
217               runReaper()
218             }
219           }
220         }
221       }
222     )
223   }
224 
225   /**
226    * This method should be called at the end of the LoaderTask. Whether or not the task has
227    * successfully loaded an update to launch, the timer will stop and the appropriate callback
228    * function will be fired.
229    */
230   @Synchronized
231   private fun finish(e: Exception?) {
232     if (hasLaunched) {
233       // we've already fired once, don't do it again
234       return
235     }
236     hasLaunched = true
237     finalizedLauncher = candidateLauncher
238     if (!isReadyToLaunch || finalizedLauncher == null || finalizedLauncher!!.launchedUpdate == null) {
239       callback.onFailure(
240         e
241           ?: Exception("LoaderTask encountered an unexpected error and could not launch an update.")
242       )
243     } else {
244       callback.onSuccess(finalizedLauncher!!, isUpToDate)
245     }
246     if (!timeoutFinished) {
247       stopTimer()
248     }
249     if (e != null) {
250       Log.e(TAG, "Unexpected error encountered while loading this app", e)
251     }
252   }
253 
254   /**
255    * This method should be called to conditionally fire the callback. If the task has successfully
256    * loaded an update to launch and the timer isn't still running, the appropriate callback function
257    * will be fired. If not, no callback will be fired.
258    */
259   @Synchronized
260   private fun maybeFinish() {
261     if (!isReadyToLaunch || !timeoutFinished) {
262       // too early, bail out
263       return
264     }
265     finish(null)
266   }
267 
268   @Synchronized
269   private fun stopTimer() {
270     timeoutFinished = true
271     handlerThread.quitSafely()
272   }
273 
274   @Synchronized
275   private fun timeout() {
276     if (!timeoutFinished) {
277       timeoutFinished = true
278       maybeFinish()
279     }
280     stopTimer()
281   }
282 
283   private fun launchFallbackUpdateFromDisk(context: Context, diskUpdateCallback: Callback) {
284     val database = databaseHolder.database
285     val launcher = DatabaseLauncher(configuration, directory, fileDownloader, selectionPolicy)
286     candidateLauncher = launcher
287     val launcherCallback: LauncherCallback = object : LauncherCallback {
288       override fun onFailure(e: Exception) {
289         databaseHolder.releaseDatabase()
290         diskUpdateCallback.onFailure(e)
291       }
292 
293       override fun onSuccess() {
294         databaseHolder.releaseDatabase()
295         diskUpdateCallback.onSuccess()
296       }
297     }
298     if (configuration.hasEmbeddedUpdate) {
299       // if the embedded update should be launched (e.g. if it's newer than any other update we have
300       // in the database, which can happen if the app binary is updated), load it into the database
301       // so we can launch it
302       val embeddedUpdate = EmbeddedManifest.get(context, configuration)!!.updateEntity
303       val launchableUpdate = launcher.getLaunchableUpdate(database, context)
304       val manifestFilters = ManifestMetadata.getManifestFilters(database, configuration)
305       if (selectionPolicy.shouldLoadNewUpdate(embeddedUpdate, launchableUpdate, manifestFilters)) {
306         EmbeddedLoader(context, configuration, database, directory).start(object : LoaderCallback {
307           override fun onFailure(e: Exception) {
308             Log.e(TAG, "Unexpected error copying embedded update", e)
309             launcher.launch(database, context, launcherCallback)
310           }
311 
312           override fun onSuccess(loaderResult: Loader.LoaderResult) {
313             launcher.launch(database, context, launcherCallback)
314           }
315 
316           override fun onAssetLoaded(
317             asset: AssetEntity,
318             successfulAssetCount: Int,
319             failedAssetCount: Int,
320             totalAssetCount: Int
321           ) {
322             // do nothing
323           }
324 
325           override fun onUpdateResponseLoaded(updateResponse: UpdateResponse): Loader.OnUpdateResponseLoadedResult {
326             return Loader.OnUpdateResponseLoadedResult(shouldDownloadManifestIfPresentInResponse = true)
327           }
328         })
329       } else {
330         launcher.launch(database, context, launcherCallback)
331       }
332     } else {
333       launcher.launch(database, context, launcherCallback)
334     }
335   }
336 
337   private fun launchRemoteUpdateInBackground(context: Context, remoteUpdateCallback: Callback) {
338     AsyncTask.execute {
339       val database = databaseHolder.database
340       callback.onRemoteCheckForUpdateStarted()
341       RemoteLoader(context, configuration, database, fileDownloader, directory, candidateLauncher?.launchedUpdate)
342         .start(object : LoaderCallback {
343           override fun onFailure(e: Exception) {
344             databaseHolder.releaseDatabase()
345             remoteUpdateCallback.onFailure(e)
346             callback.onRemoteUpdateFinished(RemoteUpdateStatus.ERROR, null, e)
347             Log.e(TAG, "Failed to download remote update", e)
348           }
349 
350           override fun onAssetLoaded(
351             asset: AssetEntity,
352             successfulAssetCount: Int,
353             failedAssetCount: Int,
354             totalAssetCount: Int
355           ) {
356             callback.onRemoteUpdateAssetLoaded(asset, successfulAssetCount, failedAssetCount, totalAssetCount)
357           }
358 
359           override fun onUpdateResponseLoaded(updateResponse: UpdateResponse): Loader.OnUpdateResponseLoadedResult {
360             val updateDirective = updateResponse.directiveUpdateResponsePart?.updateDirective
361             if (updateDirective != null) {
362               return when (updateDirective) {
363                 is UpdateDirective.RollBackToEmbeddedUpdateDirective -> {
364                   isUpToDate = true
365                   callback.onRemoteCheckForUpdateFinished(RemoteCheckResult.RollBackToEmbedded(updateDirective.commitTime))
366                   Loader.OnUpdateResponseLoadedResult(shouldDownloadManifestIfPresentInResponse = false)
367                 }
368                 is UpdateDirective.NoUpdateAvailableUpdateDirective -> {
369                   isUpToDate = true
370                   callback.onRemoteCheckForUpdateFinished(RemoteCheckResult.NoUpdateAvailable(RemoteCheckResultNotAvailableReason.NO_UPDATE_AVAILABLE_ON_SERVER))
371                   Loader.OnUpdateResponseLoadedResult(shouldDownloadManifestIfPresentInResponse = false)
372                 }
373               }
374             }
375 
376             val updateManifest = updateResponse.manifestUpdateResponsePart?.updateManifest
377             if (updateManifest == null) {
378               isUpToDate = true
379               callback.onRemoteCheckForUpdateFinished(RemoteCheckResult.NoUpdateAvailable(RemoteCheckResultNotAvailableReason.NO_UPDATE_AVAILABLE_ON_SERVER))
380               return Loader.OnUpdateResponseLoadedResult(shouldDownloadManifestIfPresentInResponse = false)
381             }
382 
383             return if (selectionPolicy.shouldLoadNewUpdate(
384                 updateManifest.updateEntity,
385                 candidateLauncher?.launchedUpdate,
386                 updateResponse.responseHeaderData?.manifestFilters
387               )
388             ) {
389               isUpToDate = false
390               callback.onRemoteUpdateManifestResponseManifestLoaded(updateManifest)
391               callback.onRemoteCheckForUpdateFinished(RemoteCheckResult.UpdateAvailable(updateManifest.manifest.getRawJson()))
392               callback.onRemoteUpdateLoadStarted()
393               Loader.OnUpdateResponseLoadedResult(shouldDownloadManifestIfPresentInResponse = true)
394             } else {
395               isUpToDate = true
396               callback.onRemoteCheckForUpdateFinished(
397                 RemoteCheckResult.NoUpdateAvailable(
398                   RemoteCheckResultNotAvailableReason.UPDATE_REJECTED_BY_SELECTION_POLICY
399                 )
400               )
401               Loader.OnUpdateResponseLoadedResult(shouldDownloadManifestIfPresentInResponse = false)
402             }
403           }
404 
405           override fun onSuccess(loaderResult: Loader.LoaderResult) {
406             RemoteLoader.processSuccessLoaderResult(
407               context,
408               configuration,
409               database,
410               selectionPolicy,
411               directory,
412               candidateLauncher?.launchedUpdate,
413               loaderResult
414             ) { availableUpdate, _ ->
415               launchUpdate(availableUpdate)
416             }
417           }
418 
419           private fun launchUpdate(availableUpdate: UpdateEntity?) {
420             // a new update (or null update because onUpdateResponseLoaded returned false or it was just a directive) has loaded successfully;
421             // we need to launch it with a new Launcher and replace the old Launcher so that the callback fires with the new one
422             val newLauncher = DatabaseLauncher(configuration, directory, fileDownloader, selectionPolicy)
423             newLauncher.launch(
424               database, context,
425               object : LauncherCallback {
426                 override fun onFailure(e: Exception) {
427                   databaseHolder.releaseDatabase()
428                   remoteUpdateCallback.onFailure(e)
429                   Log.e(TAG, "Loaded new update but it failed to launch", e)
430                 }
431 
432                 override fun onSuccess() {
433                   databaseHolder.releaseDatabase()
434                   val hasLaunchedSynchronized = synchronized(this@LoaderTask) {
435                     if (!hasLaunched) {
436                       candidateLauncher = newLauncher
437                       isUpToDate = true
438                     }
439                     hasLaunched
440                   }
441                   remoteUpdateCallback.onSuccess()
442                   if (hasLaunchedSynchronized) {
443                     if (availableUpdate == null) {
444                       callback.onRemoteUpdateFinished(
445                         RemoteUpdateStatus.NO_UPDATE_AVAILABLE,
446                         null,
447                         null
448                       )
449                     } else {
450                       callback.onRemoteUpdateFinished(
451                         RemoteUpdateStatus.UPDATE_AVAILABLE,
452                         availableUpdate,
453                         null
454                       )
455                     }
456                   }
457                 }
458               }
459             )
460           }
461         })
462     }
463   }
464 
465   private fun runReaper() {
466     AsyncTask.execute {
467       synchronized(this@LoaderTask) {
468         if (finalizedLauncher != null && finalizedLauncher!!.launchedUpdate != null) {
469           val database = databaseHolder.database
470           Reaper.reapUnusedUpdates(
471             configuration,
472             database,
473             directory,
474             finalizedLauncher!!.launchedUpdate,
475             selectionPolicy
476           )
477           databaseHolder.releaseDatabase()
478         }
479       }
480     }
481   }
482 
483   companion object {
484     private val TAG = LoaderTask::class.java.simpleName
485   }
486 }
487