@onekeyfe/react-native-bundle-update 3.0.29 → 3.0.30

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.
@@ -409,10 +409,18 @@ object BundleUpdateStoreAndroid {
409
409
  /**
410
410
  * Subtype of the most recent calculateSHA256 failure on this thread, or
411
411
  * null if the last call succeeded. Surfaces the specific reason —
412
- * FILE_NOT_FOUND / FILE_EMPTY / FILE_TRUNCATED / OOM / IO_<class> /
412
+ * FILE_NOT_FOUND / FILE_TRUNCATED / OOM / IO_<class> /
413
413
  * UNEXPECTED_<class> — so analytics can split the previously opaque
414
414
  * "Failed to calculate SHA256" bucket (mixpanel: 91.3 percent of
415
415
  * verifyPackage failures) into actionable categories.
416
+ *
417
+ * Note: 0-byte files are NOT treated as a failure. They hash to the
418
+ * well-known empty-content SHA-256 and the caller's expected/actual
419
+ * comparison handles legitimate vs. corrupt-empty cases. Rejecting
420
+ * empty files here would make any OTA bundle that legitimately
421
+ * contains a 0-byte file (touched marker, blank locale fallback)
422
+ * fail validateAllFilesInDir / validateWebEmbedSha256 / launch entry
423
+ * verification — all of which share this calculator.
416
424
  */
417
425
  fun lastSHA256FailureReason(): String? = lastSHA256Failure.get()
418
426
 
@@ -424,11 +432,6 @@ object BundleUpdateStoreAndroid {
424
432
  OneKeyLog.error("BundleUpdate", "calculateSHA256: file not found: $filePath")
425
433
  return null
426
434
  }
427
- if (file.length() == 0L) {
428
- lastSHA256Failure.set("FILE_EMPTY")
429
- OneKeyLog.error("BundleUpdate", "calculateSHA256: file empty: $filePath")
430
- return null
431
- }
432
435
  return try {
433
436
  val digest = MessageDigest.getInstance("SHA-256")
434
437
  BufferedInputStream(FileInputStream(filePath)).use { bis ->
@@ -1460,8 +1463,10 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1460
1463
  }
1461
1464
  OneKeyLog.warn("BundleUpdate", "downloadBundle: HTTP 416 (range not satisfiable), discarding partial and failing this attempt")
1462
1465
  if (partialFile.exists()) partialFile.delete()
1463
- sendEvent("update/error", message = "HTTP 416 (range not satisfiable)")
1464
- throw Exception("HTTP 416")
1466
+ // Don't pre-emit update/error here; the outer catch is the
1467
+ // single source of error events. sanitizeErrorMessageForEvent
1468
+ // recognizes "HTTP " prefix and forwards this string verbatim.
1469
+ throw Exception("HTTP 416 (range not satisfiable)")
1465
1470
  }
1466
1471
 
1467
1472
  val expectsResume = partialBytes > 0
@@ -1470,7 +1475,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1470
1475
  if (!response.isSuccessful || (response.code != 200 && response.code != 206)) {
1471
1476
  OneKeyLog.error("BundleUpdate", "downloadBundle: HTTP error, statusCode=${response.code}")
1472
1477
  response.close()
1473
- sendEvent("update/error", message = "HTTP ${response.code}")
1478
+ // outer catch is the single source of update/error events.
1474
1479
  throw Exception("HTTP ${response.code}")
1475
1480
  }
1476
1481
 
@@ -1542,7 +1547,8 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1542
1547
  if (downloadedFile.exists()) downloadedFile.delete()
1543
1548
  if (!partialAfter.renameTo(downloadedFile)) {
1544
1549
  OneKeyLog.error("BundleUpdate", "downloadBundle: rename .partial -> final failed")
1545
- sendEvent("update/error", message = "rename .partial failed")
1550
+ // outer catch is the single source of update/error events;
1551
+ // "Failed to finalize download" is in the verbatim allowlist.
1546
1552
  throw Exception("Failed to finalize download")
1547
1553
  }
1548
1554
 
@@ -1551,7 +1557,8 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1551
1557
  val reason = BundleUpdateStoreAndroid.lastSHA256FailureReason() ?: "MISMATCH"
1552
1558
  File(filePath).delete()
1553
1559
  OneKeyLog.error("BundleUpdate", "downloadBundle: SHA256 verification failed after download, reason=$reason")
1554
- sendEvent("update/error", message = "SHA256_$reason")
1560
+ // outer catch emits the verbatim "Bundle SHA256 verification
1561
+ // failed: <REASON>" payload (recognized by sanitize/JS).
1555
1562
  throw Exception("Bundle SHA256 verification failed: $reason")
1556
1563
  }
1557
1564
 
@@ -1563,12 +1570,13 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1563
1570
  // event channel must NOT carry e.message verbatim — Android
1564
1571
  // FileNotFoundException etc. embed the full /data/user/.../
1565
1572
  // path including the package identifier, and downstream
1566
- // listeners would forward that into analytics. Emit only a
1567
- // domain+code shape; mirrors the iOS sendEvent payload at
1568
- // ReactNativeBundleUpdate.swift's "update/error" sites.
1573
+ // listeners would forward that into analytics. Emit only the
1574
+ // sanitized tag; mirrors the iOS sendEvent payload at
1575
+ // ReactNativeBundleUpdate.swift's "update/error" sites and
1576
+ // matches the verbatim guarantees documented on
1577
+ // sanitizeErrorMessageForEvent.
1569
1578
  OneKeyLog.error("BundleUpdate", "downloadBundle: failed: ${e.javaClass.simpleName}: ${e.message}")
1570
- val codeTag = sanitizeErrorMessageForEvent(e)
1571
- sendEvent("update/error", message = "${e.javaClass.simpleName}: $codeTag")
1579
+ sendEvent("update/error", message = sanitizeErrorMessageForEvent(e))
1572
1580
  throw e
1573
1581
  } finally {
1574
1582
  isDownloading.set(false)
@@ -314,12 +314,20 @@ public class BundleUpdateStore: NSObject {
314
314
  }
315
315
 
316
316
  /// Subtype of the most recent calculateSHA256 failure on this thread, or
317
- /// nil if the last call succeeded. Surfaces FILE_NOT_FOUND / FILE_EMPTY /
317
+ /// nil if the last call succeeded. Surfaces FILE_NOT_FOUND /
318
318
  /// FILE_DISAPPEARED / IO_<NSError code> / UNEXPECTED so analytics can
319
319
  /// split the previously opaque "Failed to calculate SHA256" bucket
320
320
  /// (mixpanel: 91.3% of verifyPackage failures are Android; iOS shares
321
321
  /// the calculator and inherits the same blind spot for its 14 ASC +
322
322
  /// 2 verifyPackage Promise-destroyed cases).
323
+ ///
324
+ /// Note: 0-byte files are NOT treated as a failure. They hash to the
325
+ /// well-known empty-content SHA256 and the caller's expected/actual
326
+ /// comparison handles legitimate vs. corrupt-empty cases. Rejecting
327
+ /// empty files here would make any OTA bundle that legitimately
328
+ /// contains a 0-byte file (touched marker, blank locale fallback)
329
+ /// fail validateAllFilesInDir / validateWebEmbedSha256 / launch entry
330
+ /// verification — all of which share this calculator.
323
331
  public static func lastSHA256FailureReason() -> String? {
324
332
  return Thread.current.threadDictionary[kSHA256FailureKey] as? String
325
333
  }
@@ -340,13 +348,6 @@ public class BundleUpdateStore: NSObject {
340
348
  OneKeyLog.error("BundleUpdate", "calculateSHA256: file not found: \(filePath)")
341
349
  return nil
342
350
  }
343
- let attrs = (try? fm.attributesOfItem(atPath: filePath)) ?? [:]
344
- let fileSize = (attrs[.size] as? NSNumber)?.int64Value ?? -1
345
- if fileSize == 0 {
346
- setSHA256Failure("FILE_EMPTY")
347
- OneKeyLog.error("BundleUpdate", "calculateSHA256: file empty: \(filePath)")
348
- return nil
349
- }
350
351
  guard let fileHandle = FileHandle(forReadingAtPath: filePath) else {
351
352
  setSHA256Failure("FILE_DISAPPEARED")
352
353
  OneKeyLog.error("BundleUpdate", "calculateSHA256: open failed (file disappeared between stat and open): \(filePath)")
@@ -376,7 +377,11 @@ public class BundleUpdateStore: NSObject {
376
377
  }) {}
377
378
  if let err = threwError {
378
379
  let nsErr = err as NSError
379
- setSHA256Failure("IO_\(nsErr.domain)_\(nsErr.code)")
380
+ // Keep the failure tag low-cardinality (`IO_<code>`) so it
381
+ // matches the doc on lastSHA256FailureReason and stays under
382
+ // the analytics bucket cap. The full `domain code description`
383
+ // detail is still logged below for local debugging.
384
+ setSHA256Failure("IO_\(nsErr.code)")
380
385
  OneKeyLog.error("BundleUpdate", "calculateSHA256: read failed: \(nsErr.domain) \(nsErr.code) \(nsErr.localizedDescription)")
381
386
  return nil
382
387
  }
@@ -1249,17 +1254,22 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1249
1254
  /// On iOS, force-quit (user swipes the app off the App Switcher) cannot
1250
1255
  /// fire `URLSession`'s `didCompleteWithError` — SIGKILL leaves no time
1251
1256
  /// for callbacks. The kill is, however, *always* preceded by the app
1252
- /// transitioning to the background. We hook that transition and call
1253
- /// `cancel(byProducingResumeData:)` synchronously, so the resume blob
1254
- /// is on disk before the app can possibly be terminated. Memory-pressure
1255
- /// kills follow the same chain (the OS only reaps backgrounded apps
1256
- /// under memory pressure), so this also covers OOM termination.
1257
+ /// transitioning to the background. We hook that transition and kick
1258
+ /// off `cancel(byProducingResumeData:)` for any in-flight downloads so
1259
+ /// the resume blob lands on disk before the app can be terminated.
1260
+ /// Memory-pressure kills follow the same chain (the OS only reaps
1261
+ /// backgrounded apps under memory pressure), so this also covers OOM
1262
+ /// termination.
1257
1263
  ///
1258
- /// `beginBackgroundTask` extends the ~5s of guaranteed background runtime
1259
- /// to ~30s, which is plenty for a few-KB blob write but we don't rely
1260
- /// on the extension to *complete* the snapshot; the `cancel` callback
1261
- /// returns the data synchronously, so even if iOS suspends us right
1262
- /// after `endBackgroundTask` the resume blob is already persisted.
1264
+ /// Both `URLSession.getAllTasks(_:)` and `cancel(byProducingResumeData:)`
1265
+ /// deliver their results *asynchronously* via a closurethe resume
1266
+ /// data does not pop out synchronously. We wrap the work in a
1267
+ /// `beginBackgroundTask` window that extends the ~5s of guaranteed
1268
+ /// background runtime to ~30s so the closures actually have time to
1269
+ /// fire and write the few-KB blob before suspension. Persistence is
1270
+ /// best-effort: if iOS reaps us before the writer closure runs (very
1271
+ /// short background windows, expiration), the next launch simply
1272
+ /// re-downloads from scratch — a resume miss is correct, just slower.
1263
1273
  private func registerBackgroundSnapshotObserver() {
1264
1274
  didEnterBackgroundObserver = NotificationCenter.default.addObserver(
1265
1275
  forName: UIApplication.didEnterBackgroundNotification,
@@ -1284,14 +1294,35 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1284
1294
 
1285
1295
  let resumeDataPath = "\(filePath).resume"
1286
1296
  let bgTaskName = "BundleUpdateResumeSnapshot"
1287
- var bgTaskId: UIBackgroundTaskIdentifier = .invalid
1288
- bgTaskId = UIApplication.shared.beginBackgroundTask(withName: bgTaskName) {
1289
- // Expiration handler system is reclaiming us. Best-effort end.
1290
- if bgTaskId != .invalid {
1291
- UIApplication.shared.endBackgroundTask(bgTaskId)
1292
- bgTaskId = .invalid
1297
+
1298
+ // bgTaskId is mutated from three escaping closures: the
1299
+ // beginBackgroundTask expiration handler (called on main),
1300
+ // session.getAllTasks's completion (NOT guaranteed to be main),
1301
+ // and group.notify on .main. Wrap reads/writes in a tiny holder
1302
+ // serialized through a dedicated queue so we cannot double-end
1303
+ // the background task or leak it. `endOnce` guarantees endBackgroundTask
1304
+ // is invoked exactly once across whichever closure reaches it first.
1305
+ final class BgTaskHolder {
1306
+ private let q = DispatchQueue(label: "so.onekey.bundleupdate.bgtask")
1307
+ private var id: UIBackgroundTaskIdentifier = .invalid
1308
+ func set(_ newId: UIBackgroundTaskIdentifier) {
1309
+ q.sync { id = newId }
1310
+ }
1311
+ func endOnce() {
1312
+ q.sync {
1313
+ if id != .invalid {
1314
+ UIApplication.shared.endBackgroundTask(id)
1315
+ id = .invalid
1316
+ }
1317
+ }
1293
1318
  }
1294
1319
  }
1320
+ let bgTask = BgTaskHolder()
1321
+ let started = UIApplication.shared.beginBackgroundTask(withName: bgTaskName) {
1322
+ // Expiration handler — system is reclaiming us. Best-effort end.
1323
+ bgTask.endOnce()
1324
+ }
1325
+ bgTask.set(started)
1295
1326
  OneKeyLog.info("BundleUpdate", "didEnterBackground: snapshotting resumeData for \(filePath)")
1296
1327
 
1297
1328
  session.getAllTasks { tasks in
@@ -1318,10 +1349,7 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1318
1349
  })
1319
1350
  }
1320
1351
  group.notify(queue: .main) {
1321
- if bgTaskId != .invalid {
1322
- UIApplication.shared.endBackgroundTask(bgTaskId)
1323
- bgTaskId = .invalid
1324
- }
1352
+ bgTask.endOnce()
1325
1353
  }
1326
1354
  }
1327
1355
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-bundle-update",
3
- "version": "3.0.29",
3
+ "version": "3.0.30",
4
4
  "description": "react-native-bundle-update",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",