<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