@appsonair.ir/react-native 1.0.12 → 1.0.13

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.
@@ -9,6 +9,14 @@
9
9
  android:process=":ota_restart"
10
10
  android:theme="@android:style/Theme.Translucent.NoTitleBar" />
11
11
 
12
+ <receiver
13
+ android:name=".OTAApkInstallReceiver"
14
+ android:exported="false">
15
+ <intent-filter>
16
+ <action android:name="com.otaupdater.APK_INSTALL_STATUS" />
17
+ </intent-filter>
18
+ </receiver>
19
+
12
20
  <provider
13
21
  android:name=".OTAFirebaseInitProvider"
14
22
  android:authorities="${applicationId}.otaupdater.firebaseinit"
@@ -1,31 +1,40 @@
1
1
  package com.otaupdater
2
2
 
3
+ import android.app.PendingIntent
4
+ import android.content.BroadcastReceiver
3
5
  import android.content.Context
4
6
  import android.content.Intent
7
+ import android.content.pm.PackageInfo
8
+ import android.content.pm.PackageInstaller
9
+ import android.content.pm.PackageManager
5
10
  import android.net.Uri
6
11
  import android.os.Build
7
12
  import android.provider.Settings
8
13
  import android.util.Log
9
- import androidx.core.content.FileProvider
10
14
  import com.facebook.react.bridge.ReactApplicationContext
11
15
  import java.io.BufferedInputStream
12
16
  import java.io.File
17
+ import java.io.FileInputStream
13
18
  import java.io.FileOutputStream
19
+ import java.security.MessageDigest
14
20
  import java.util.zip.ZipFile
15
21
 
16
22
  object OTAApkInstallHelper {
17
23
  private const val TAG = "OTAApkInstall"
18
24
  private const val MIN_APK_BYTES = 1024L * 100
25
+ const val ACTION_INSTALL_STATUS = "com.otaupdater.APK_INSTALL_STATUS"
19
26
 
20
27
  /**
21
28
  * @return "started" when the system install screen opens, "permission_required" when settings were opened
22
29
  */
23
30
  fun install(reactContext: ReactApplicationContext, path: String): String {
24
- val prepared = prepareInstallableApk(File(normalizePath(path)))
31
+ val prepared = prepareInstallableApk(reactContext, File(normalizePath(path)))
25
32
  return installPrepared(reactContext, prepared)
26
33
  }
27
34
 
28
35
  fun installPrepared(reactContext: ReactApplicationContext, prepared: File): String {
36
+ validateApkForInstall(reactContext, prepared)
37
+
29
38
  if (needsInstallPermission(reactContext) &&
30
39
  !reactContext.packageManager.canRequestPackageInstalls()) {
31
40
  OTAUpdaterStorage.setPendingApkPath(reactContext, prepared.absolutePath)
@@ -33,7 +42,7 @@ object OTAApkInstallHelper {
33
42
  return "permission_required"
34
43
  }
35
44
 
36
- launchInstallIntent(reactContext, prepared)
45
+ commitPackageInstallerSession(reactContext, prepared)
37
46
  OTAUpdaterStorage.clearPendingApkPath(reactContext)
38
47
  return "started"
39
48
  }
@@ -48,8 +57,9 @@ object OTAApkInstallHelper {
48
57
  }
49
58
 
50
59
  try {
51
- val prepared = prepareInstallableApk(File(path))
52
- launchInstallIntent(reactContext, prepared)
60
+ val prepared = prepareInstallableApk(reactContext, File(path))
61
+ validateApkForInstall(reactContext, prepared)
62
+ commitPackageInstallerSession(reactContext, prepared)
53
63
  OTAUpdaterStorage.clearPendingApkPath(reactContext)
54
64
  } catch (e: Exception) {
55
65
  Log.e(TAG, "Failed to resume APK install", e)
@@ -61,6 +71,30 @@ object OTAApkInstallHelper {
61
71
  * Ensures [file] is a real APK (has AndroidManifest.xml).
62
72
  * If it is an outer zip that contains an .apk entry, extracts that APK first.
63
73
  */
74
+ fun prepareInstallableApk(reactContext: ReactApplicationContext, file: File): File {
75
+ if (!file.exists() || file.length() < MIN_APK_BYTES) {
76
+ throw IllegalArgumentException("APK file is missing or invalid: ${file.absolutePath}")
77
+ }
78
+
79
+ val apkFile =
80
+ if (isAndroidApk(file)) {
81
+ file
82
+ } else {
83
+ val unwrapped = unwrapApkFromZip(file)
84
+ if (unwrapped != null && isAndroidApk(unwrapped)) {
85
+ Log.i(TAG, "Unwrapped nested APK from zip: ${unwrapped.absolutePath}")
86
+ unwrapped
87
+ } else {
88
+ throw IllegalArgumentException(
89
+ "Wrong package: file is not a valid Android APK (${file.name}). " +
90
+ "Expected an APK or a zip that contains one.",
91
+ )
92
+ }
93
+ }
94
+
95
+ return copyToInstallCache(reactContext, apkFile)
96
+ }
97
+
64
98
  fun prepareInstallableApk(file: File): File {
65
99
  if (!file.exists() || file.length() < MIN_APK_BYTES) {
66
100
  throw IllegalArgumentException("APK file is missing or invalid: ${file.absolutePath}")
@@ -84,6 +118,136 @@ object OTAApkInstallHelper {
84
118
 
85
119
  fun isAndroidApk(file: File): Boolean = hasAndroidManifest(file)
86
120
 
121
+ /**
122
+ * Catch the usual "App not installed" causes before the system UI fails opaquely:
123
+ * versionCode downgrade / same build, and signing-key mismatch.
124
+ */
125
+ fun validateApkForInstall(context: Context, apkFile: File) {
126
+ val pm = context.packageManager
127
+ val archive =
128
+ readArchivePackageInfo(pm, apkFile)
129
+ ?: throw IllegalArgumentException(
130
+ "Wrong package: Android cannot parse this APK (${apkFile.name})",
131
+ )
132
+
133
+ val packageName =
134
+ archive.packageName
135
+ ?: throw IllegalArgumentException("Wrong package: APK has no package name")
136
+
137
+ val newCode = packageLongVersionCode(archive)
138
+
139
+ val installed =
140
+ try {
141
+ readInstalledPackageInfo(pm, packageName)
142
+ } catch (_: PackageManager.NameNotFoundException) {
143
+ Log.i(TAG, "APK $packageName is a fresh install (versionCode=$newCode)")
144
+ return
145
+ }
146
+
147
+ val oldCode = packageLongVersionCode(installed)
148
+ if (newCode < oldCode) {
149
+ throw IllegalArgumentException(
150
+ "Cannot install: APK versionCode $newCode is older than installed $oldCode. " +
151
+ "Upload an APK with a higher versionCode.",
152
+ )
153
+ }
154
+ if (newCode == oldCode) {
155
+ throw IllegalArgumentException(
156
+ "Cannot install: APK versionCode $newCode is already installed. " +
157
+ "Bump versionCode (build number) and upload again.",
158
+ )
159
+ }
160
+
161
+ if (!signaturesMatch(installed, archive)) {
162
+ throw IllegalArgumentException(
163
+ "Cannot install: APK is signed with a different key than the installed app. " +
164
+ "Use the same keystore that signed the app currently on the device.",
165
+ )
166
+ }
167
+
168
+ Log.i(
169
+ TAG,
170
+ "APK OK for update: $packageName versionCode $oldCode → $newCode (${apkFile.length()} bytes)",
171
+ )
172
+ }
173
+
174
+ private fun commitPackageInstallerSession(context: Context, apkFile: File) {
175
+ val installer = context.packageManager.packageInstaller
176
+ val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
177
+
178
+ val sessionId = installer.createSession(params)
179
+ val session = installer.openSession(sessionId)
180
+
181
+ try {
182
+ FileInputStream(apkFile).use { input ->
183
+ session.openWrite("package", 0, apkFile.length()).use { output ->
184
+ input.copyTo(output)
185
+ session.fsync(output)
186
+ }
187
+ }
188
+
189
+ val action = Intent(ACTION_INSTALL_STATUS).apply {
190
+ setPackage(context.packageName)
191
+ }
192
+ val flags =
193
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
194
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
195
+ } else {
196
+ PendingIntent.FLAG_UPDATE_CURRENT
197
+ }
198
+ val pending =
199
+ PendingIntent.getBroadcast(context, sessionId, action, flags)
200
+
201
+ session.commit(pending.intentSender)
202
+ Log.i(TAG, "PackageInstaller session $sessionId committed for ${apkFile.name}")
203
+ } catch (e: Exception) {
204
+ try {
205
+ session.abandon()
206
+ } catch (_: Exception) {
207
+ // Ignore abandon errors.
208
+ }
209
+ throw e
210
+ } finally {
211
+ try {
212
+ session.close()
213
+ } catch (_: Exception) {
214
+ // Ignore close errors.
215
+ }
216
+ }
217
+ }
218
+
219
+ private fun copyToInstallCache(context: Context, apkFile: File): File {
220
+ val cacheDir = File(context.filesDir, "ota-apk-install")
221
+ if (!cacheDir.exists() && !cacheDir.mkdirs()) {
222
+ Log.w(TAG, "Could not create install cache dir; using source APK")
223
+ return apkFile
224
+ }
225
+
226
+ val dest = File(cacheDir, "update.apk")
227
+ if (dest.absolutePath == apkFile.absolutePath) {
228
+ return apkFile
229
+ }
230
+
231
+ try {
232
+ if (dest.exists()) {
233
+ dest.delete()
234
+ }
235
+ apkFile.inputStream().use { input ->
236
+ FileOutputStream(dest).use { output ->
237
+ input.copyTo(output)
238
+ }
239
+ }
240
+ if (dest.length() != apkFile.length() || !isAndroidApk(dest)) {
241
+ dest.delete()
242
+ return apkFile
243
+ }
244
+ return dest
245
+ } catch (e: Exception) {
246
+ Log.w(TAG, "Failed to copy APK to install cache; using source", e)
247
+ return apkFile
248
+ }
249
+ }
250
+
87
251
  private fun hasAndroidManifest(file: File): Boolean {
88
252
  return try {
89
253
  ZipFile(file).use { zip ->
@@ -149,6 +313,15 @@ object OTAApkInstallHelper {
149
313
  }
150
314
  }
151
315
 
316
+ if (entry.size > 0 && out.length() != entry.size) {
317
+ Log.e(
318
+ TAG,
319
+ "Unwrapped APK size mismatch: got ${out.length()}, expected ${entry.size}",
320
+ )
321
+ out.delete()
322
+ return null
323
+ }
324
+
152
325
  if (out.length() < MIN_APK_BYTES) {
153
326
  out.delete()
154
327
  return null
@@ -175,20 +348,6 @@ object OTAApkInstallHelper {
175
348
  private fun needsInstallPermission(context: Context): Boolean =
176
349
  Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
177
350
 
178
- private fun launchInstallIntent(context: Context, file: File) {
179
- val authority = "${context.packageName}.otaupdater.fileprovider"
180
- val uri = FileProvider.getUriForFile(context, authority, file)
181
-
182
- val installIntent =
183
- Intent(Intent.ACTION_VIEW).apply {
184
- setDataAndType(uri, "application/vnd.android.package-archive")
185
- addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
186
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
187
- }
188
-
189
- context.startActivity(installIntent)
190
- }
191
-
192
351
  private fun openUnknownSourcesSettings(reactContext: ReactApplicationContext) {
193
352
  val intent =
194
353
  Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
@@ -204,5 +363,125 @@ object OTAApkInstallHelper {
204
363
  }
205
364
  }
206
365
 
366
+ private fun readArchivePackageInfo(pm: PackageManager, apkFile: File): PackageInfo? {
367
+ val info =
368
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
369
+ pm.getPackageArchiveInfo(
370
+ apkFile.absolutePath,
371
+ PackageManager.PackageInfoFlags.of(PackageManager.GET_SIGNING_CERTIFICATES.toLong()),
372
+ )
373
+ } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
374
+ @Suppress("DEPRECATION")
375
+ pm.getPackageArchiveInfo(apkFile.absolutePath, PackageManager.GET_SIGNING_CERTIFICATES)
376
+ } else {
377
+ @Suppress("DEPRECATION")
378
+ pm.getPackageArchiveInfo(apkFile.absolutePath, PackageManager.GET_SIGNATURES)
379
+ }
380
+
381
+ info?.applicationInfo?.sourceDir = apkFile.absolutePath
382
+ info?.applicationInfo?.publicSourceDir = apkFile.absolutePath
383
+ return info
384
+ }
385
+
386
+ private fun readInstalledPackageInfo(pm: PackageManager, packageName: String): PackageInfo {
387
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
388
+ pm.getPackageInfo(
389
+ packageName,
390
+ PackageManager.PackageInfoFlags.of(PackageManager.GET_SIGNING_CERTIFICATES.toLong()),
391
+ )
392
+ } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
393
+ @Suppress("DEPRECATION")
394
+ pm.getPackageInfo(packageName, PackageManager.GET_SIGNING_CERTIFICATES)
395
+ } else {
396
+ @Suppress("DEPRECATION")
397
+ pm.getPackageInfo(packageName, PackageManager.GET_SIGNATURES)
398
+ }
399
+ }
400
+
401
+ private fun packageLongVersionCode(info: PackageInfo): Long {
402
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
403
+ info.longVersionCode
404
+ } else {
405
+ @Suppress("DEPRECATION")
406
+ info.versionCode.toLong()
407
+ }
408
+ }
409
+
410
+ private fun signaturesMatch(installed: PackageInfo, archive: PackageInfo): Boolean {
411
+ val installedDigests = signingDigests(installed)
412
+ val archiveDigests = signingDigests(archive)
413
+ if (installedDigests.isEmpty() || archiveDigests.isEmpty()) {
414
+ // Some devices omit archive signing info — don't block install on missing data.
415
+ Log.w(TAG, "Could not compare APK signatures; continuing install")
416
+ return true
417
+ }
418
+ return installedDigests.any { it in archiveDigests }
419
+ }
420
+
421
+ private fun signingDigests(info: PackageInfo): Set<String> {
422
+ val digests = linkedSetOf<String>()
423
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
424
+ val signingInfo = info.signingInfo ?: return digests
425
+ val signers =
426
+ if (signingInfo.hasMultipleSigners()) {
427
+ signingInfo.apkContentsSigners
428
+ } else {
429
+ signingInfo.signingCertificateHistory
430
+ }
431
+ for (signature in signers) {
432
+ digests.add(sha256Hex(signature.toByteArray()))
433
+ }
434
+ } else {
435
+ @Suppress("DEPRECATION")
436
+ val signatures = info.signatures ?: return digests
437
+ for (signature in signatures) {
438
+ digests.add(sha256Hex(signature.toByteArray()))
439
+ }
440
+ }
441
+ return digests
442
+ }
443
+
444
+ private fun sha256Hex(bytes: ByteArray): String {
445
+ val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
446
+ return digest.joinToString("") { "%02x".format(it) }
447
+ }
448
+
207
449
  private fun normalizePath(path: String): String = path.removePrefix("file://")
208
450
  }
451
+
452
+ class OTAApkInstallReceiver : BroadcastReceiver() {
453
+ override fun onReceive(context: Context, intent: Intent?) {
454
+ if (intent == null) return
455
+
456
+ val status =
457
+ intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
458
+ val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
459
+
460
+ when (status) {
461
+ PackageInstaller.STATUS_PENDING_USER_ACTION -> {
462
+ val confirm =
463
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
464
+ intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
465
+ } else {
466
+ @Suppress("DEPRECATION")
467
+ intent.getParcelableExtra(Intent.EXTRA_INTENT)
468
+ }
469
+ if (confirm != null) {
470
+ confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
471
+ context.startActivity(confirm)
472
+ } else {
473
+ Log.e(OTAApkInstallHelper.TAG, "Install pending user action but EXTRA_INTENT missing")
474
+ }
475
+ }
476
+ PackageInstaller.STATUS_SUCCESS -> {
477
+ Log.i(OTAApkInstallHelper.TAG, "APK installed successfully")
478
+ }
479
+ else -> {
480
+ Log.e(
481
+ OTAApkInstallHelper.TAG,
482
+ "APK install failed status=$status message=${message ?: "unknown"}",
483
+ )
484
+ }
485
+ }
486
+ }
487
+ }
@@ -110,7 +110,11 @@ class OTAUpdaterModule(private val reactContext: ReactApplicationContext) :
110
110
  fun installApk(path: String, promise: Promise) {
111
111
  Thread {
112
112
  try {
113
- val prepared = OTAApkInstallHelper.prepareInstallableApk(File(path.removePrefix("file://")))
113
+ val prepared =
114
+ OTAApkInstallHelper.prepareInstallableApk(
115
+ reactContext,
116
+ File(path.removePrefix("file://")),
117
+ )
114
118
  UiThreadUtil.runOnUiThread {
115
119
  try {
116
120
  val result = OTAApkInstallHelper.installPrepared(reactContext, prepared)
package/lib/sync.d.ts CHANGED
@@ -77,7 +77,12 @@ export declare function checkForUpdate(serverUrl: string, deploymentKey: string,
77
77
  export declare function getBundleReleaseDir(label: string): string;
78
78
  export declare function getBundleFilePath(label: string): string;
79
79
  export declare function getApkFilePath(label: string): string;
80
- export declare function downloadBundle(downloadUrl: string, deploymentKey: string, label: string, expectedSize?: number, onProgress?: (progress: number, received?: number, total?: number) => void, extension?: 'bundle' | 'apk', deviceAbi?: string, packageFormat?: 'bundle' | 'zip', expectedHash?: string): Promise<string>;
80
+ export declare function downloadBundle(downloadUrl: string, deploymentKey: string, label: string, expectedSize?: number, onProgress?: (progress: number, received?: number, total?: number) => void, extension?: 'bundle' | 'apk', deviceAbi?: string, packageFormat?: 'bundle' | 'zip', expectedHash?: string,
81
+ /**
82
+ * When false, APK zip downloads are left as `.zip` for native unwrap/install.
83
+ * JS-side unzip has produced invalid `.apk` files on some devices.
84
+ */
85
+ extractApk?: boolean): Promise<string>;
81
86
  export declare function verifyBundleHash(filePath: string, expectedHash: string): Promise<boolean>;
82
87
  type ProgressCallback = (status: SyncStatus, progress?: number) => void;
83
88
  export declare function isSyncInProgress(): boolean;
package/lib/sync.js CHANGED
@@ -750,7 +750,7 @@ async function cleanupPartialDownload(path) {
750
750
  * bytesWritten !== Content-Length — including when Content-Length is wrong
751
751
  * but the full file actually landed. Recover those cases via size/hash checks.
752
752
  */
753
- async function tryRecoverCompletedDownload(destPath, extension, totalBytes, expectedHash, packageFormat, label, deviceAbi) {
753
+ async function tryRecoverCompletedDownload(destPath, extension, totalBytes, expectedHash, packageFormat, label, deviceAbi, extractApk = true) {
754
754
  try {
755
755
  const ReactNativeBlobUtil = require('react-native-blob-util').default;
756
756
  const exists = await ReactNativeBlobUtil.fs.exists(destPath);
@@ -769,7 +769,9 @@ async function tryRecoverCompletedDownload(destPath, extension, totalBytes, expe
769
769
  else if (!sizeLooksComplete) {
770
770
  return null;
771
771
  }
772
- if (extension === 'apk' && (await shouldExtractApkZip(destPath, packageFormat))) {
772
+ if (extractApk &&
773
+ extension === 'apk' &&
774
+ (await shouldExtractApkZip(destPath, packageFormat))) {
773
775
  return extractApkUpdate(label, destPath, deviceAbi);
774
776
  }
775
777
  if (extension === 'bundle' && (await shouldExtractZipPackage(destPath, packageFormat))) {
@@ -781,7 +783,12 @@ async function tryRecoverCompletedDownload(destPath, extension, totalBytes, expe
781
783
  return null;
782
784
  }
783
785
  }
784
- async function downloadBundle(downloadUrl, deploymentKey, label, expectedSize, onProgress, extension = 'bundle', deviceAbi, packageFormat = 'bundle', expectedHash) {
786
+ async function downloadBundle(downloadUrl, deploymentKey, label, expectedSize, onProgress, extension = 'bundle', deviceAbi, packageFormat = 'bundle', expectedHash,
787
+ /**
788
+ * When false, APK zip downloads are left as `.zip` for native unwrap/install.
789
+ * JS-side unzip has produced invalid `.apk` files on some devices.
790
+ */
791
+ extractApk = true) {
785
792
  const ReactNativeBlobUtil = require('react-native-blob-util').default;
786
793
  const isZipBundle = extension === 'bundle' && packageFormat === 'zip';
787
794
  const isApkZip = extension === 'apk' && packageFormat === 'zip';
@@ -876,7 +883,9 @@ async function downloadBundle(downloadUrl, deploymentKey, label, expectedSize, o
876
883
  throw new Error('Wrong package: hash verification failed');
877
884
  }
878
885
  }
879
- if (extension === 'apk' && (await shouldExtractApkZip(destPath, packageFormat))) {
886
+ if (extractApk &&
887
+ extension === 'apk' &&
888
+ (await shouldExtractApkZip(destPath, packageFormat))) {
880
889
  return extractApkUpdate(label, destPath, deviceAbi);
881
890
  }
882
891
  if (extension === 'bundle' && (await shouldExtractZipPackage(destPath, packageFormat))) {
@@ -887,7 +896,7 @@ async function downloadBundle(downloadUrl, deploymentKey, label, expectedSize, o
887
896
  catch (error) {
888
897
  lastError = error;
889
898
  if (isRetryableDownloadError(error)) {
890
- const recovered = await tryRecoverCompletedDownload(destPath, extension, totalBytes, expectedHash, packageFormat, label, deviceAbi);
899
+ const recovered = await tryRecoverCompletedDownload(destPath, extension, totalBytes, expectedHash, packageFormat, label, deviceAbi, extractApk);
891
900
  if (recovered) {
892
901
  if (onProgress)
893
902
  onProgress(1);
@@ -1175,22 +1184,23 @@ async function performApkSync(update, showUI, messages, onProgress, changelog, c
1175
1184
  }
1176
1185
  const notes = changelog ?? getUpdateChangelog(update);
1177
1186
  const progressExtra = buildUpdateProgressExtra(update, undefined, currentLabel);
1187
+ // Prefer zip (smaller). Native unwraps the APK before PackageInstaller runs.
1178
1188
  const packageFormat = update.packageFormat ?? 'zip';
1179
1189
  try {
1180
1190
  reportProgress(showUI, messages, onProgress, types_1.SyncStatus.DOWNLOADING, 0, progressExtra);
1181
- const apkPath = await downloadBundle(update.downloadUrl, (0, config_1.getConfig)().deploymentKey, update.label, update.size, (p, received, total) => reportProgress(showUI, messages, onProgress, types_1.SyncStatus.DOWNLOADING, p, {
1191
+ const packagePath = await downloadBundle(update.downloadUrl, (0, config_1.getConfig)().deploymentKey, update.label, update.size, (p, received, total) => reportProgress(showUI, messages, onProgress, types_1.SyncStatus.DOWNLOADING, p, {
1182
1192
  ...progressExtra,
1183
1193
  downloadedBytes: received,
1184
1194
  totalBytes: total && total > 0 ? total : progressExtra.totalBytes,
1185
- }), 'apk', update.deviceAbi, packageFormat, update.packageHash);
1186
- if (!apkPath.toLowerCase().endsWith('.apk')) {
1187
- throw new Error('Wrong package: expected an APK after zip extract');
1195
+ }), 'apk', update.deviceAbi, packageFormat, update.packageHash, false);
1196
+ const lowerPath = packagePath.toLowerCase();
1197
+ if (!lowerPath.endsWith('.apk') && !lowerPath.endsWith('.zip')) {
1198
+ throw new Error('Wrong package: expected an APK or zip-of-APK download');
1188
1199
  }
1189
1200
  onProgress?.(types_1.SyncStatus.INSTALLING, 1);
1190
- // Drop the OTA JS path before opening the installer so the new APK does not
1191
- // keep loading the previous bundle on first launch after install.
1192
- await clearUpdate();
1193
- const installResult = await installApk(apkPath);
1201
+ // Drop only the OTA JS bundle do not delete the APK/zip about to install.
1202
+ await clearJsBundleState();
1203
+ const installResult = await installApk(packagePath);
1194
1204
  if (installResult === 'permission_required') {
1195
1205
  reportProgress(showUI, messages, onProgress, types_1.SyncStatus.INSTALLING, 1, {
1196
1206
  ...progressExtra,
@@ -1242,7 +1252,8 @@ async function getInstalledBundlePath() {
1242
1252
  }
1243
1253
  return null;
1244
1254
  }
1245
- async function clearUpdate() {
1255
+ /** Clear persisted JS OTA state without touching APK/zip install candidates. */
1256
+ async function clearJsBundleState() {
1246
1257
  const meta = await getStoredBundleMeta();
1247
1258
  await clearStoredBundleMeta();
1248
1259
  if (OTAUpdaterNative?.clearBundlePath) {
@@ -1252,6 +1263,12 @@ async function clearUpdate() {
1252
1263
  await removePathRecursive(getBundleReleaseDir(meta.label));
1253
1264
  await cleanupPartialDownload(getBundleZipPath(meta.label));
1254
1265
  await cleanupPartialDownload(getLegacyBundleFilePath(meta.label));
1266
+ }
1267
+ }
1268
+ async function clearUpdate() {
1269
+ const meta = await getStoredBundleMeta();
1270
+ await clearJsBundleState();
1271
+ if (meta?.label) {
1255
1272
  await cleanupPartialDownload(getApkFilePath(meta.label));
1256
1273
  await cleanupPartialDownload(getApkZipPath(meta.label));
1257
1274
  await removePathRecursive(getApkExtractDir(meta.label));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appsonair.ir/react-native",
3
- "version": "1.0.12",
3
+ "version": "1.0.13",
4
4
  "description": "React Native client SDK for self-hosted OTA updates",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",