@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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
-
|
|
1187
|
-
|
|
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
|
|
1191
|
-
|
|
1192
|
-
await
|
|
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
|
-
|
|
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));
|