expo-updates 56.0.25 → 56.0.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,30 @@
10
10
 
11
11
  ### 💡 Others
12
12
 
13
+ ## 56.0.27 — 2026-09-17
14
+
15
+ ### 🐛 Bug fixes
16
+
17
+ - [iOS] Apply bundle diffs against the embedded bundle in the app binary when the launched update is the embedded one, instead of failing to resolve a patch base and downloading the full bundle. ([#50018](https://github.com/expo/expo/pull/50018) by [@alanjhughes](https://github.com/alanjhughes))
18
+ - [Android] Apply bundle diffs against the embedded bundle in the app binary when the launched update is the embedded one, instead of failing to resolve a patch base and downloading the full bundle. ([#50019](https://github.com/expo/expo/pull/50019) by [@alanjhughes](https://github.com/alanjhughes))
19
+
20
+ ## 56.0.26 — 2026-08-28
21
+
22
+ ### 🐛 Bug fixes
23
+
24
+ - [Android] Register the embedded update in a single transaction. An interrupted registration previously left an update row with no launch asset, which is treated as launchable and then fails every cold start with "Launch asset not found for update"; it now leaves no row, so the next launch registers it cleanly. ([#49130](https://github.com/expo/expo/pull/49130) by [@gwdp](https://github.com/gwdp))
25
+ - [Android] Widen `UpdatesLogEntry.create`'s catch from `JSONException` to `Exception` so log-line parse failures consistently degrade to "skip the entry" instead of propagating. ([#46182](https://github.com/expo/expo/pull/46182) by [@jakequade-pc](https://github.com/jakequade-pc))
26
+ - [Android] Correct `UpdatesLogReader.ONE_DAY_MILLISECONDS` from `86400` (seconds) to `86_400_000` (milliseconds), so the "older than one day" purge filter actually retains a day's worth of entries instead of ~86 seconds' worth. ([#46182](https://github.com/expo/expo/pull/46182) by [@jakequade-pc](https://github.com/jakequade-pc))
27
+ - [iOS] Propagate database failures from `addNewAssets` instead of swallowing them, which let an update be marked ready without its launch asset and then fail every launch. ([#49456](https://github.com/expo/expo/pull/49456) by [@alanjhughes](https://github.com/alanjhughes))
28
+ - [iOS] Repair ready updates that are missing their launch asset by demoting them to pending during launcher selection, so a corrupted row is skipped and retried instead of failing every launch. ([#49457](https://github.com/expo/expo/pull/49457) by [@alanjhughes](https://github.com/alanjhughes))
29
+ - [Android] Skip and repair updates that are missing their launch asset instead of selecting them for launch, which previously failed every cold start with "Launch asset not found for update". ([#49470](https://github.com/expo/expo/pull/49470) by [@alanjhughes](https://github.com/alanjhughes), based on [#48733](https://github.com/expo/expo/pull/48733) by [@martintreurnicht](https://github.com/martintreurnicht))
30
+ - [iOS] Fix the embedded manifest recording the wrong `packagerHash` for assets that ship scale variants iOS does not allow (such as `@1.5x` and `@4x`): the hashes were read by the filtered scale index instead of the asset's own, so those images resolved to an empty URI and rendered blank in release builds. ([#48811](https://github.com/expo/expo/pull/48811) by [@expo-bot](https://github.com/expo-bot))
31
+
32
+ ### 💡 Others
33
+
34
+ - [Android] Replace the "this should never happen" wording in the missing launch asset error with the likely cause and how the state resolves. ([#49130](https://github.com/expo/expo/pull/49130) by [@gwdp](https://github.com/gwdp))
35
+ - [Android] Log purge completion errors via `android.util.Log.e` directly instead of `logger.error`, so the failure path doesn't re-enter the `PersistentFileLog` dispatch queue from inside one of its own tasks. ([#46182](https://github.com/expo/expo/pull/46182) by [@jakequade-pc](https://github.com/jakequade-pc))
36
+
13
37
  ## 56.0.25 — 2026-08-17
14
38
 
15
39
  ### 🐛 Bug fixes
@@ -42,7 +42,7 @@ expoModule {
42
42
  }
43
43
 
44
44
  group = 'host.exp.exponent'
45
- version = '56.0.25'
45
+ version = '56.0.27'
46
46
 
47
47
  // Utility method to derive boolean values from the environment or from Java properties,
48
48
  // and return them as strings to be used in BuildConfig fields
@@ -89,7 +89,7 @@ android {
89
89
  namespace "expo.modules.updates"
90
90
  defaultConfig {
91
91
  versionCode 31
92
- versionName '56.0.25'
92
+ versionName '56.0.27'
93
93
  consumerProguardFiles("proguard-rules.pro")
94
94
  testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
95
95
 
@@ -4,6 +4,7 @@ import android.app.Activity
4
4
  import android.content.Context
5
5
  import android.net.Uri
6
6
  import android.os.Bundle
7
+ import android.util.Log
7
8
  import com.facebook.react.ReactHost
8
9
  import com.facebook.react.bridge.ReactContext
9
10
  import com.facebook.react.devsupport.interfaces.DevSupportManager
@@ -18,7 +19,6 @@ import expo.modules.updates.events.IUpdatesEventManager
18
19
  import expo.modules.updates.events.UpdatesEventManager
19
20
  import expo.modules.updates.launcher.Launcher.LauncherCallback
20
21
  import expo.modules.updates.loader.FileDownloader
21
- import expo.modules.updates.logging.UpdatesErrorCode
22
22
  import expo.modules.updates.logging.UpdatesLogReader
23
23
  import expo.modules.updates.logging.UpdatesLogger
24
24
  import expo.modules.updates.manifest.EmbeddedManifestUtils
@@ -90,7 +90,12 @@ class EnabledUpdatesController(
90
90
  private fun purgeUpdatesLogsOlderThanOneDay() {
91
91
  UpdatesLogReader(context.filesDir).purgeLogEntries {
92
92
  if (it != null) {
93
- logger.error("UpdatesLogReader: error in purgeLogEntries", it, UpdatesErrorCode.Unknown)
93
+ // Log directly via android.util.Log rather than through `logger.error`,
94
+ // which writes via the PersistentFileLog dispatch queue. This callback
95
+ // is invoked from inside one of that queue's own tasks, so feeding
96
+ // another entry back into the queue from here re-enters it. Bypassing
97
+ // the queue keeps the failure path off its own back.
98
+ Log.e("expo-updates", "UpdatesLogReader: error in purgeLogEntries", it)
94
99
  }
95
100
  }
96
101
  }
@@ -87,7 +87,7 @@ class DatabaseLauncher(
87
87
  // verify that we have all assets on disk
88
88
  // according to the database, we should, but something could have gone wrong on disk
89
89
  val launchAsset = database.updateDao().loadLaunchAssetForUpdate(launchedUpdate!!.id)
90
- ?: throw Exception("Launch asset not found for update; this should never happen. Debug info: ${launchedUpdate!!.debugInfo()}")
90
+ ?: throw Exception("Launch asset not found for update. The update row has no launch asset; an interrupted registration in an older version of expo-updates can leave an update in this state, and it is replaced once a newer update is downloaded. Debug info: ${launchedUpdate!!.debugInfo()}")
91
91
 
92
92
  if (launchAsset.relativePath == null) {
93
93
  throw Exception("Launch asset relative path should not be null. Debug info: ${launchedUpdate!!.debugInfo()}")
@@ -161,6 +161,15 @@ class DatabaseLauncher(
161
161
  if (!configuration.hasEmbeddedUpdate && embeddedUpdate?.updateEntity?.id == update.id) {
162
162
  continue
163
163
  }
164
+
165
+ // An update with no launch asset can never launch. Excluding it here lets the loader
166
+ // re-run and repair the row instead of failing every cold start.
167
+ if (update.status != UpdateStatus.DEVELOPMENT &&
168
+ database.updateDao().loadLaunchAssetForUpdate(update.id) == null
169
+ ) {
170
+ logger.warn("Skipping launchable update with no launch asset. Debug info: ${update.debugInfo()}")
171
+ continue
172
+ }
164
173
  filteredLaunchableUpdates.add(update)
165
174
  }
166
175
  val manifestFilters = ManifestMetadata.getManifestFilters(database, configuration)
@@ -22,6 +22,7 @@ import expo.modules.updates.manifest.ResponsePartHeaderData
22
22
  import expo.modules.updates.manifest.ResponsePartInfo
23
23
  import expo.modules.updates.manifest.UpdateFactory
24
24
  import expo.modules.updates.selectionpolicy.SelectionPolicies
25
+ import expo.modules.updates.utils.AndroidResourceAssetUtils
25
26
  import kotlinx.coroutines.suspendCancellableCoroutine
26
27
  import okhttp3.Cache
27
28
  import okhttp3.Headers
@@ -50,6 +51,7 @@ import kotlin.math.min
50
51
 
51
52
  private const val PATCH_TEMP_SUFFIX = ".patch"
52
53
  private const val PATCHED_TEMP_SUFFIX = ".patched"
54
+ private const val PATCH_BASE_TEMP_SUFFIX = ".base"
53
55
  private const val A_IM_HEADER = "A-IM"
54
56
  private const val IM_HEADER = "im"
55
57
  private const val EXPO_BASE_UPDATE_ID_RESPONSE_HEADER = "expo-base-update-id"
@@ -57,6 +59,8 @@ private const val EXPO_CURRENT_UPDATE_ID_HEADER = "Expo-Current-Update-ID"
57
59
  private const val EXPO_REQUESTED_UPDATE_ID_HEADER = "Expo-Requested-Update-ID"
58
60
  private const val EXPO_EMBEDDED_UPDATE_ID_HEADER = "Expo-Embedded-Update-ID"
59
61
 
62
+ internal typealias EmbeddedAssetExtractor = (AssetEntity, File) -> Unit
63
+
60
64
  /**
61
65
  * Utility class that holds all the logic for downloading data and files, such as update manifests
62
66
  * and assets, using an instance of [OkHttpClient].
@@ -106,7 +110,8 @@ class FileDownloader(
106
110
  progressListener: FileDownloadProgressListener? = null,
107
111
  allowPatch: Boolean,
108
112
  launchedUpdate: UpdateEntity? = null,
109
- requestedUpdate: UpdateEntity? = null
113
+ requestedUpdate: UpdateEntity? = null,
114
+ embeddedAssetExtractor: EmbeddedAssetExtractor? = null
110
115
  ): FileDownloadResult {
111
116
  try {
112
117
  val response = downloadData(request, progressListener)
@@ -175,7 +180,8 @@ class FileDownloader(
175
180
  updatesDirectory = updatesDirectory,
176
181
  launchedUpdate = launchedUpdate,
177
182
  requestedUpdate = requestedUpdate,
178
- expectedBase64URLEncodedSHA256Hash = expectedBase64URLEncodedSHA256Hash
183
+ expectedBase64URLEncodedSHA256Hash = expectedBase64URLEncodedSHA256Hash,
184
+ embeddedAssetExtractor = embeddedAssetExtractor
179
185
  )
180
186
  }.getOrElse {
181
187
  logger.warn(
@@ -293,36 +299,47 @@ class FileDownloader(
293
299
  updatesDirectory: File,
294
300
  launchedUpdate: UpdateEntity,
295
301
  requestedUpdate: UpdateEntity?,
296
- expectedBase64URLEncodedSHA256Hash: String?
302
+ expectedBase64URLEncodedSHA256Hash: String?,
303
+ embeddedAssetExtractor: EmbeddedAssetExtractor? = null
297
304
  ): ByteArray {
298
305
  val launchAssetContext = prepareAssetForDiff(
299
306
  asset = asset,
300
307
  responseBody = responseBody,
308
+ destination = destination,
301
309
  updatesDirectory = updatesDirectory,
302
- launchedUpdate = launchedUpdate
310
+ launchedUpdate = launchedUpdate,
311
+ embeddedAssetExtractor = embeddedAssetExtractor
303
312
  )
304
313
 
305
- return applyHermesDiff(
306
- baseFile = launchAssetContext.baseFile,
307
- diffBody = responseBody,
308
- destination = destination,
309
- expectedBase64URLEncodedSHA256Hash = expectedBase64URLEncodedSHA256Hash,
310
- asset = asset,
311
- requestedUpdateId = requestedUpdate?.id?.toString()
312
- )
314
+ try {
315
+ return applyHermesDiff(
316
+ baseFile = launchAssetContext.baseFile,
317
+ diffBody = responseBody,
318
+ destination = destination,
319
+ expectedBase64URLEncodedSHA256Hash = expectedBase64URLEncodedSHA256Hash,
320
+ asset = asset,
321
+ requestedUpdateId = requestedUpdate?.id?.toString()
322
+ )
323
+ } finally {
324
+ if (launchAssetContext.isTemporary) {
325
+ launchAssetContext.baseFile.delete()
326
+ }
327
+ }
313
328
  }
314
329
 
315
- internal data class LaunchAssetContext(val baseFile: File)
330
+ internal data class LaunchAssetContext(val baseFile: File, val isTemporary: Boolean = false)
316
331
 
317
332
  @VisibleForTesting
318
333
  internal fun prepareAssetForDiff(
319
334
  asset: AssetEntity,
320
335
  responseBody: ResponseBody,
336
+ destination: File,
321
337
  updatesDirectory: File,
322
- launchedUpdate: UpdateEntity
338
+ launchedUpdate: UpdateEntity,
339
+ embeddedAssetExtractor: EmbeddedAssetExtractor? = null
323
340
  ): LaunchAssetContext {
324
341
  return try {
325
- preparePatchBaseAsset(asset, updatesDirectory, launchedUpdate)
342
+ preparePatchBaseAsset(asset, destination, updatesDirectory, launchedUpdate, embeddedAssetExtractor)
326
343
  } catch (e: Exception) {
327
344
  responseBody.close()
328
345
  if (e is IOException) {
@@ -335,8 +352,10 @@ class FileDownloader(
335
352
 
336
353
  private fun preparePatchBaseAsset(
337
354
  asset: AssetEntity,
355
+ destination: File,
338
356
  updatesDirectory: File,
339
- launchedUpdate: UpdateEntity
357
+ launchedUpdate: UpdateEntity,
358
+ embeddedAssetExtractor: EmbeddedAssetExtractor? = null
340
359
  ): LaunchAssetContext {
341
360
  if (!asset.isLaunchAsset) {
342
361
  throw IOException("Received patch for non-launch asset ${asset.key}")
@@ -350,29 +369,60 @@ class FileDownloader(
350
369
  val launchAssetRelativePath = launchAssetEntity.relativePath
351
370
  ?: throw IOException("Launch asset for update $currentUpdateId is missing a relative path")
352
371
 
353
- val baseFile = File(updatesDirectory, launchAssetRelativePath)
372
+ // BSPatch needs a real path, so an asset inside the APK is extracted first.
373
+ val isEmbeddedLaunchAsset = AndroidResourceAssetUtils.isAndroidResourceAsset(launchAssetRelativePath)
374
+ val baseFile = if (isEmbeddedLaunchAsset) {
375
+ extractEmbeddedPatchBaseAsset(launchAssetEntity, destination, embeddedAssetExtractor)
376
+ } else {
377
+ File(updatesDirectory, launchAssetRelativePath)
378
+ }
354
379
  if (!baseFile.exists()) {
355
380
  throw IOException("Base asset $baseFile is missing; cannot apply patch")
356
381
  }
357
382
 
358
- val actualBaseHash = try {
359
- UpdatesUtils.toBase64Url(UpdatesUtils.sha256(baseFile))
360
- } catch (_: Exception) {
361
- null
383
+ val expectedBaseHash = launchAssetEntity.expectedHash
384
+ if (expectedBaseHash != null) {
385
+ val actualBaseHash = try {
386
+ UpdatesUtils.toBase64Url(UpdatesUtils.sha256(baseFile))
387
+ } catch (_: Exception) {
388
+ null
389
+ }
390
+ if (actualBaseHash != null && expectedBaseHash != actualBaseHash) {
391
+ logger.warn(
392
+ "Asset hash mismatch for update $currentUpdateId; expected=$expectedBaseHash actual=$actualBaseHash",
393
+ UpdatesErrorCode.AssetsFailedToLoad,
394
+ currentUpdateId.toString(),
395
+ asset.key
396
+ )
397
+ if (isEmbeddedLaunchAsset) {
398
+ baseFile.delete()
399
+ }
400
+ throw IOException("Asset hash mismatch for update $currentUpdateId; expected=$expectedBaseHash actual=$actualBaseHash")
401
+ }
362
402
  }
363
403
 
364
- val expectedBaseHash = launchAssetEntity.expectedHash
365
- if (expectedBaseHash != null && actualBaseHash != null && expectedBaseHash != actualBaseHash) {
366
- logger.warn(
367
- "Asset hash mismatch for update $currentUpdateId; expected=$expectedBaseHash actual=$actualBaseHash",
368
- UpdatesErrorCode.AssetsFailedToLoad,
369
- currentUpdateId.toString(),
370
- asset.key
371
- )
372
- throw IOException("Asset hash mismatch for update $currentUpdateId; expected=$expectedBaseHash actual=$actualBaseHash")
404
+ return LaunchAssetContext(baseFile, isTemporary = isEmbeddedLaunchAsset)
405
+ }
406
+
407
+ @Throws(IOException::class)
408
+ private fun extractEmbeddedPatchBaseAsset(
409
+ launchAssetEntity: AssetEntity,
410
+ destination: File,
411
+ embeddedAssetExtractor: EmbeddedAssetExtractor?
412
+ ): File {
413
+ if (embeddedAssetExtractor == null) {
414
+ throw IOException("The launch asset lives in the app binary and cannot be extracted without an extractor")
373
415
  }
374
416
 
375
- return LaunchAssetContext(baseFile)
417
+ val baseFile = File(destination.absolutePath + PATCH_BASE_TEMP_SUFFIX)
418
+ try {
419
+ baseFile.parentFile?.mkdirs()
420
+ embeddedAssetExtractor(launchAssetEntity, baseFile)
421
+ } catch (e: Exception) {
422
+ baseFile.delete()
423
+ throw e as? IOException ?: IOException("Failed to extract the embedded launch asset", e)
424
+ }
425
+ return baseFile
376
426
  }
377
427
 
378
428
  @VisibleForTesting
@@ -677,7 +727,8 @@ class FileDownloader(
677
727
  extraHeaders: JSONObject,
678
728
  launchedUpdate: UpdateEntity?,
679
729
  requestedUpdate: UpdateEntity?,
680
- assetLoadProgressListener: ((Double) -> Unit)? = null
730
+ assetLoadProgressListener: ((Double) -> Unit)? = null,
731
+ embeddedAssetExtractor: EmbeddedAssetExtractor? = null
681
732
  ): AssetDownloadResult {
682
733
  if (asset.url == null) {
683
734
  val message = "Failed to download asset ${asset.key}"
@@ -721,7 +772,8 @@ class FileDownloader(
721
772
  assetLoadProgressListener?.let { listener -> { listener.invoke(it) } },
722
773
  allowPatch = canApplyPatch,
723
774
  launchedUpdate = launchedUpdate,
724
- requestedUpdate = requestedUpdate
775
+ requestedUpdate = requestedUpdate,
776
+ embeddedAssetExtractor = embeddedAssetExtractor
725
777
  )
726
778
 
727
779
  asset.downloadTime = Date()
@@ -1,6 +1,7 @@
1
1
  package expo.modules.updates.loader
2
2
 
3
3
  import android.content.Context
4
+ import androidx.room.withTransaction
4
5
  import expo.modules.updates.UpdatesConfiguration
5
6
  import expo.modules.updates.UpdatesUtils
6
7
  import expo.modules.updates.db.UpdatesDatabase
@@ -17,6 +18,7 @@ import expo.modules.updates.logging.UpdatesErrorCode
17
18
  import expo.modules.updates.logging.UpdatesLogger
18
19
  import expo.modules.updates.manifest.ManifestMetadata
19
20
  import expo.modules.updates.manifest.Update
21
+ import kotlinx.coroutines.CancellationException
20
22
  import kotlinx.coroutines.CoroutineScope
21
23
  import kotlinx.coroutines.Dispatchers
22
24
  import kotlinx.coroutines.SupervisorJob
@@ -164,21 +166,32 @@ abstract class Loader protected constructor(
164
166
  database.updateDao().setUpdateScopeKey(existingUpdateEntity, newUpdateEntity.scopeKey)
165
167
  }
166
168
 
167
- if (existingUpdateEntity != null && existingUpdateEntity.status == UpdateStatus.READY) {
169
+ // A READY update with no launch asset is not actually ready. Fall through to
170
+ // downloadAllAssets so its assets are re-registered instead of staying broken forever.
171
+ if (existingUpdateEntity != null && existingUpdateEntity.status == UpdateStatus.READY &&
172
+ database.updateDao().loadLaunchAssetForUpdate(existingUpdateEntity.id) != null
173
+ ) {
168
174
  // hooray, we already have this update downloaded and ready to go!
169
175
  updateEntity = existingUpdateEntity
170
176
  return finish()
171
177
  } else {
178
+ val insertUpdateEntityOnFinish: Boolean
172
179
  if (existingUpdateEntity == null) {
173
- // no update already exists with this ID, so we need to insert it and download everything.
180
+ // no update already exists with this ID, so we need to download everything.
174
181
  updateEntity = newUpdateEntity
175
- database.updateDao().insertUpdate(updateEntity!!)
182
+ // EMBEDDED is already in the launchable set, so a row inserted before its launch asset exists
183
+ // gets picked as launchable and then fails every launch.
184
+ insertUpdateEntityOnFinish = newUpdateEntity.status == UpdateStatus.EMBEDDED
185
+ if (!insertUpdateEntityOnFinish) {
186
+ database.updateDao().insertUpdate(updateEntity!!)
187
+ }
176
188
  } else {
177
189
  // we've already partially downloaded the update, so we should use the existing entity.
178
190
  // however, it's not ready, so we should try to download all the assets again.
179
191
  updateEntity = existingUpdateEntity
192
+ insertUpdateEntityOnFinish = false
180
193
  }
181
- return downloadAllAssets(update)
194
+ return downloadAllAssets(update, insertUpdateEntityOnFinish)
182
195
  }
183
196
  }
184
197
 
@@ -188,7 +201,7 @@ abstract class Loader protected constructor(
188
201
  ERRORED
189
202
  }
190
203
 
191
- private suspend fun downloadAllAssets(update: Update): LoaderResult {
204
+ private suspend fun downloadAllAssets(update: Update, insertUpdateEntityOnFinish: Boolean): LoaderResult {
192
205
  val assetList = update.assetEntityList.distinctBy { it.key }
193
206
  assetTotal = assetList.size
194
207
 
@@ -235,26 +248,34 @@ abstract class Loader protected constructor(
235
248
  assetDownloadJobs.awaitAll()
236
249
 
237
250
  try {
238
- for (asset in existingAssetList) {
239
- val existingAssetFound = database.assetDao()
240
- .addExistingAssetToUpdate(updateEntity!!, asset, asset.isLaunchAsset)
241
- if (!existingAssetFound) {
242
- // the database and filesystem have gotten out of sync
243
- // do our best to create a new entry for this file even though it already existed on disk
244
- // TODO: we should probably get rid of this assumption that if an asset exists on disk with the same filename, it's the same asset
245
- var hash: ByteArray? = null
246
- try {
247
- hash = UpdatesUtils.sha256(File(updatesDirectory, asset.relativePath))
248
- } catch (_: Exception) {
251
+ database.withTransaction {
252
+ if (insertUpdateEntityOnFinish) {
253
+ database.updateDao().insertUpdate(updateEntity!!)
254
+ }
255
+
256
+ for (asset in existingAssetList) {
257
+ val existingAssetFound = database.assetDao()
258
+ .addExistingAssetToUpdate(updateEntity!!, asset, asset.isLaunchAsset)
259
+ if (!existingAssetFound) {
260
+ // the database and filesystem have gotten out of sync
261
+ // do our best to create a new entry for this file even though it already existed on disk
262
+ // TODO: we should probably get rid of this assumption that if an asset exists on disk with the same filename, it's the same asset
263
+ var hash: ByteArray? = null
264
+ try {
265
+ hash = UpdatesUtils.sha256(File(updatesDirectory, asset.relativePath))
266
+ } catch (_: Exception) {
267
+ }
268
+ asset.downloadTime = Date()
269
+ asset.hash = hash
270
+ finishedAssetList.add(asset)
249
271
  }
250
- asset.downloadTime = Date()
251
- asset.hash = hash
252
- finishedAssetList.add(asset)
253
272
  }
254
- }
255
273
 
256
- database.assetDao().insertAssets(finishedAssetList, updateEntity!!)
257
- database.updateDao().markUpdateFinished(updateEntity!!)
274
+ database.assetDao().insertAssets(finishedAssetList, updateEntity!!)
275
+ database.updateDao().markUpdateFinished(updateEntity!!)
276
+ }
277
+ } catch (e: CancellationException) {
278
+ throw e
258
279
  } catch (e: Exception) {
259
280
  throw IOException("Error while adding new update to database", e)
260
281
  }
@@ -14,6 +14,7 @@ import kotlinx.coroutines.CoroutineScope
14
14
  import kotlinx.coroutines.Dispatchers
15
15
  import kotlinx.coroutines.SupervisorJob
16
16
  import java.io.File
17
+ import java.io.IOException
17
18
 
18
19
  data class ProcessSuccessLoaderResult(
19
20
  val availableUpdate: UpdateEntity?,
@@ -66,9 +67,28 @@ class RemoteLoader internal constructor(
66
67
  embeddedUpdate: UpdateEntity?
67
68
  ): FileDownloader.AssetDownloadResult {
68
69
  val extraHeaders = FileDownloader.getExtraHeadersForRemoteAssetRequest(launchedUpdate, embeddedUpdate, requestedUpdate)
69
- return mFileDownloader.downloadAsset(assetEntity, updatesDirectory, extraHeaders, launchedUpdate, requestedUpdate) { progress ->
70
- assetLoadProgressListener(assetEntity, progress)
71
- }
70
+ return mFileDownloader.downloadAsset(
71
+ asset = assetEntity,
72
+ destinationDirectory = updatesDirectory,
73
+ extraHeaders = extraHeaders,
74
+ launchedUpdate = launchedUpdate,
75
+ requestedUpdate = requestedUpdate,
76
+ embeddedAssetExtractor = { launchAsset, destination ->
77
+ // The stored path is not the APK asset name; resolve it through the embedded manifest by key.
78
+ val embeddedLaunchAsset = loaderFiles.readEmbeddedUpdate(context, configuration)
79
+ ?.assetEntityList
80
+ ?.find { it.key == launchAsset.key }
81
+ ?: throw IOException("No embedded asset matches launch asset ${launchAsset.key}")
82
+ val assetName = embeddedLaunchAsset.embeddedAssetFilename
83
+ ?: throw IOException("Embedded launch asset ${launchAsset.key} has no APK asset filename")
84
+ context.assets.open(assetName).use { input ->
85
+ destination.outputStream().use { output -> input.copyTo(output) }
86
+ }
87
+ },
88
+ assetLoadProgressListener = { progress ->
89
+ assetLoadProgressListener(assetEntity, progress)
90
+ }
91
+ )
72
92
  }
73
93
 
74
94
  companion object {
@@ -3,7 +3,6 @@ package expo.modules.updates.logging
3
3
  import expo.modules.jsonutils.getNullable
4
4
  import expo.modules.jsonutils.require
5
5
  import org.json.JSONArray
6
- import org.json.JSONException
7
6
  import org.json.JSONObject
8
7
 
9
8
  /**
@@ -59,7 +58,11 @@ data class UpdatesLogEntry(
59
58
  List(jsonArray.length()) { i -> jsonArray.getString(i) }
60
59
  }
61
60
  )
62
- } catch (e: JSONException) {
61
+ } catch (e: Exception) {
62
+ // `JSONObject.require<T>` can surface a ClassCastException via a blind
63
+ // cast in the catch-all branch (e.g. an int-sized numeric `duration`
64
+ // token surfaces as `Integer`), which would otherwise escape and
65
+ // propagate to the caller.
63
66
  null
64
67
  }
65
68
  }
@@ -54,6 +54,6 @@ class UpdatesLogReader(
54
54
  }
55
55
 
56
56
  companion object {
57
- private const val ONE_DAY_MILLISECONDS = 86400
57
+ private const val ONE_DAY_MILLISECONDS = 86_400_000L
58
58
  }
59
59
  }
@@ -84,6 +84,12 @@ public final class FileDownloader {
84
84
  private let updatesDirectory: URL
85
85
  private let database: UpdatesDatabase
86
86
 
87
+ /// Overridable for testing, where the fixture is not in `updatesBundle`.
88
+ internal var embeddedLaunchAssetUrl: URL? = updatesBundle.url(
89
+ forResource: EmbeddedAppLoader.EXUpdatesBareEmbeddedBundleFilename,
90
+ withExtension: EmbeddedAppLoader.EXUpdatesBareEmbeddedBundleFileType
91
+ )
92
+
87
93
  public convenience init(
88
94
  config: UpdatesConfig,
89
95
  logger: UpdatesLogger,
@@ -558,8 +564,7 @@ public final class FileDownloader {
558
564
  throw DiffError.assetNotLaunch
559
565
  }
560
566
 
561
- let baseAsset = try resolveLaunchAsset(launchedUpdate: launchedUpdate)
562
- let baseFileUrl = try loadAndVerifyAsset(baseAsset)
567
+ let baseFileUrl = try resolveBaseFileUrl(launchedUpdate: launchedUpdate)
563
568
  let requestedUpdateId = requestedUpdate?.updateId.uuidString
564
569
 
565
570
  return try createPatchedAsset(
@@ -572,6 +577,19 @@ public final class FileDownloader {
572
577
  )
573
578
  }
574
579
 
580
+ private func resolveBaseFileUrl(launchedUpdate: Update) throws -> URL {
581
+ if launchedUpdate.status == UpdateStatus.StatusEmbedded {
582
+ // StatusEmbedded only launches for this binary's embedded update.
583
+ guard let embeddedLaunchAssetUrl else {
584
+ throw DiffError.embeddedBaseAssetMissing
585
+ }
586
+ return embeddedLaunchAssetUrl
587
+ }
588
+
589
+ let baseAsset = try resolveLaunchAsset(launchedUpdate: launchedUpdate)
590
+ return try loadAndVerifyAsset(baseAsset)
591
+ }
592
+
575
593
  private func resolveLaunchAsset(launchedUpdate: Update) throws -> UpdateAsset {
576
594
  let currentUpdateId = launchedUpdate.updateId
577
595
  var launchAsset: UpdateAsset?
@@ -1239,6 +1257,7 @@ extension FileDownloader {
1239
1257
  case missingHeader(String)
1240
1258
  case invalidHeader(String)
1241
1259
  case launchAssetNotFound
1260
+ case embeddedBaseAssetMissing
1242
1261
  case baseAssetMissing(path: String)
1243
1262
  case failedToReadBaseAsset(cause: Error)
1244
1263
  case failedToWritePatch(cause: Error, path: String)
@@ -1266,6 +1285,8 @@ extension FileDownloader.DiffError: CustomStringConvertible {
1266
1285
  return "Invalid \(header) header"
1267
1286
  case .launchAssetNotFound:
1268
1287
  return "Launch asset not found for current update"
1288
+ case .embeddedBaseAssetMissing:
1289
+ return "Embedded bundle not found in the app binary"
1269
1290
  case let .baseAssetMissing(path):
1270
1291
  return "Base asset is missing at path \(path)"
1271
1292
  case let .failedToReadBaseAsset(cause):
@@ -160,7 +160,7 @@ public final class UpdatesDatabase: NSObject {
160
160
  )
161
161
  } catch {
162
162
  sqlite3_exec(db, "ROLLBACK;", nil, nil, nil)
163
- return
163
+ throw error
164
164
  }
165
165
 
166
166
  // statements must stay in precisely this order for last_insert_rowid() to work correctly
@@ -170,7 +170,7 @@ public final class UpdatesDatabase: NSObject {
170
170
  _ = try execute(sql: updateSql, withArgs: [updateId])
171
171
  } catch {
172
172
  sqlite3_exec(db, "ROLLBACK;", nil, nil, nil)
173
- return
173
+ throw error
174
174
  }
175
175
  }
176
176
 
@@ -181,7 +181,7 @@ public final class UpdatesDatabase: NSObject {
181
181
  _ = try execute(sql: updateInsertSql, withArgs: [updateId])
182
182
  } catch {
183
183
  sqlite3_exec(db, "ROLLBACK;", nil, nil, nil)
184
- return
184
+ throw error
185
185
  }
186
186
  }
187
187
 
@@ -444,7 +444,23 @@ public final class UpdatesDatabase: NSObject {
444
444
  )
445
445
 
446
446
  let rows = try execute(sql: sql, withArgs: [config.scopeKey])
447
- return rows.map { row in
447
+
448
+ // A ready row with no launch asset is corrupt (e.g. from an interrupted registration) and
449
+ // would fail every launch. Demote it for the loader to retry instead of offering it.
450
+ var launchableRows: [[String: Any?]] = []
451
+ for row in rows {
452
+ let status: NSNumber = row.requiredValue(forKey: "status")
453
+ let launchAssetId: NSNumber? = row.optionalValue(forKey: "launch_asset_id")
454
+ if status.intValue == UpdateStatus.StatusReady.rawValue && launchAssetId == nil {
455
+ let updateId: UUID = row.requiredValue(forKey: "id")
456
+ let demoteSql = "UPDATE updates SET status = ?1 WHERE id = ?2;"
457
+ _ = try execute(sql: demoteSql, withArgs: [UpdateStatus.StatusPending.rawValue, updateId])
458
+ } else {
459
+ launchableRows.append(row)
460
+ }
461
+ }
462
+
463
+ return launchableRows.map { row in
448
464
  update(withRow: row, config: config)
449
465
  }
450
466
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-updates",
3
- "version": "56.0.25",
3
+ "version": "56.0.27",
4
4
  "description": "Fetches and manages remotely-hosted assets and updates to your app's JS bundle.",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -60,10 +60,10 @@
60
60
  "picomatch": "^4.0.4",
61
61
  "ts-node": "^10.9.2",
62
62
  "xstate": "^4.37.2",
63
- "expo-dev-client": "56.0.25",
64
- "expo-module-scripts": "56.0.3",
65
- "expo": "56.0.20",
66
- "@expo/metro-config": "56.0.18"
63
+ "@expo/metro-config": "56.0.19",
64
+ "expo": "56.0.22",
65
+ "expo-dev-client": "56.0.27",
66
+ "expo-module-scripts": "56.0.3"
67
67
  },
68
68
  "peerDependencies": {
69
69
  "expo": "*",
@@ -82,7 +82,7 @@
82
82
  "./scripts/with-node.sh"
83
83
  ]
84
84
  },
85
- "gitHead": "39f9dc47fb5ddd099a2533dd0ff24be7126a1ed0",
85
+ "gitHead": "6a613ddc65139388dc1d25ccef2e88721fc66384",
86
86
  "scripts": {
87
87
  "build": "expo-module build",
88
88
  "clean": "expo-module clean",
@@ -64,12 +64,14 @@ export async function createManifestForBuildAsync(
64
64
  'The hashAssetFiles Metro plugin is not configured. You need to add a metro.config.js to your project that configures Metro to use this plugin. See https://github.com/expo/expo/blob/main/packages/expo-updates/README.md#metroconfigjs for an example.'
65
65
  );
66
66
  }
67
- filterPlatformAssetScales(platform, asset.scales).forEach(function (scale, index) {
67
+ filterPlatformAssetScales(platform, asset.scales).forEach(function (scale) {
68
68
  const baseAssetInfoForManifest = {
69
69
  name: asset.name,
70
70
  type: asset.type,
71
71
  scale,
72
- packagerHash: asset.fileHashes[index],
72
+ // `fileHashes` is parallel to the unfiltered `asset.scales`, so it must be indexed by the
73
+ // scale's position there rather than by its position in the filtered list.
74
+ packagerHash: asset.fileHashes[asset.scales.indexOf(scale)],
73
75
  subdirectory: asset.httpServerLocation,
74
76
  };
75
77
  if (platform === 'ios') {