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 +24 -0
- package/android/build.gradle +2 -2
- package/android/src/main/java/expo/modules/updates/EnabledUpdatesController.kt +7 -2
- package/android/src/main/java/expo/modules/updates/launcher/DatabaseLauncher.kt +10 -1
- package/android/src/main/java/expo/modules/updates/loader/FileDownloader.kt +85 -33
- package/android/src/main/java/expo/modules/updates/loader/Loader.kt +43 -22
- package/android/src/main/java/expo/modules/updates/loader/RemoteLoader.kt +23 -3
- package/android/src/main/java/expo/modules/updates/logging/UpdatesLogEntry.kt +5 -2
- package/android/src/main/java/expo/modules/updates/logging/UpdatesLogReader.kt +1 -1
- package/ios/EXUpdates/AppLoader/FileDownloader.swift +23 -2
- package/ios/EXUpdates/Database/UpdatesDatabase.swift +20 -4
- package/package.json +6 -6
- package/utils/src/createManifestForBuildAsync.ts +4 -2
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
|
package/android/build.gradle
CHANGED
|
@@ -42,7 +42,7 @@ expoModule {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
group = 'host.exp.exponent'
|
|
45
|
-
version = '56.0.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
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
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
180
|
+
// no update already exists with this ID, so we need to download everything.
|
|
174
181
|
updateEntity = newUpdateEntity
|
|
175
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
.
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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(
|
|
70
|
-
|
|
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:
|
|
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
|
}
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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-
|
|
64
|
-
"expo
|
|
65
|
-
"expo": "56.0.
|
|
66
|
-
"
|
|
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": "
|
|
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
|
|
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
|
-
|
|
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') {
|