@appsonair.ir/react-native 1.0.10 → 1.0.11

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.
@@ -5,47 +5,35 @@ import android.content.Intent
5
5
  import android.net.Uri
6
6
  import android.os.Build
7
7
  import android.provider.Settings
8
+ import android.util.Log
8
9
  import androidx.core.content.FileProvider
9
10
  import com.facebook.react.bridge.ReactApplicationContext
11
+ import java.io.BufferedInputStream
10
12
  import java.io.File
13
+ import java.io.FileOutputStream
14
+ import java.util.zip.ZipFile
11
15
 
12
16
  object OTAApkInstallHelper {
17
+ private const val TAG = "OTAApkInstall"
13
18
  private const val MIN_APK_BYTES = 1024L * 100
14
19
 
15
20
  /**
16
21
  * @return "started" when the system install screen opens, "permission_required" when settings were opened
17
22
  */
18
23
  fun install(reactContext: ReactApplicationContext, path: String): String {
19
- val file = File(normalizePath(path))
20
- if (!file.exists() || file.length() < MIN_APK_BYTES) {
21
- throw IllegalArgumentException("APK file is missing or invalid: ${file.absolutePath}")
22
- }
23
-
24
- if (file.name.endsWith(".zip", ignoreCase = true)) {
25
- throw IllegalArgumentException(
26
- "Downloaded zip was not extracted to an APK before install: ${file.name}",
27
- )
28
- }
29
-
30
- val archiveInfo =
31
- reactContext.packageManager.getPackageArchiveInfo(
32
- file.absolutePath,
33
- 0,
34
- )
35
- if (archiveInfo == null) {
36
- throw IllegalArgumentException(
37
- "Wrong package: file is not a valid Android APK (${file.name})",
38
- )
39
- }
24
+ val prepared = prepareInstallableApk(File(normalizePath(path)))
25
+ return installPrepared(reactContext, prepared)
26
+ }
40
27
 
28
+ fun installPrepared(reactContext: ReactApplicationContext, prepared: File): String {
41
29
  if (needsInstallPermission(reactContext) &&
42
30
  !reactContext.packageManager.canRequestPackageInstalls()) {
43
- OTAUpdaterStorage.setPendingApkPath(reactContext, file.absolutePath)
31
+ OTAUpdaterStorage.setPendingApkPath(reactContext, prepared.absolutePath)
44
32
  openUnknownSourcesSettings(reactContext)
45
33
  return "permission_required"
46
34
  }
47
35
 
48
- launchInstallIntent(reactContext, file)
36
+ launchInstallIntent(reactContext, prepared)
49
37
  OTAUpdaterStorage.clearPendingApkPath(reactContext)
50
38
  return "started"
51
39
  }
@@ -59,14 +47,129 @@ object OTAApkInstallHelper {
59
47
  return
60
48
  }
61
49
 
62
- val file = File(path)
63
- if (!file.exists() || file.length() < MIN_APK_BYTES) {
50
+ try {
51
+ val prepared = prepareInstallableApk(File(path))
52
+ launchInstallIntent(reactContext, prepared)
53
+ OTAUpdaterStorage.clearPendingApkPath(reactContext)
54
+ } catch (e: Exception) {
55
+ Log.e(TAG, "Failed to resume APK install", e)
64
56
  OTAUpdaterStorage.clearPendingApkPath(reactContext)
65
- return
66
57
  }
58
+ }
67
59
 
68
- launchInstallIntent(reactContext, file)
69
- OTAUpdaterStorage.clearPendingApkPath(reactContext)
60
+ /**
61
+ * Ensures [file] is a real APK (has AndroidManifest.xml).
62
+ * If it is an outer zip that contains an .apk entry, extracts that APK first.
63
+ */
64
+ fun prepareInstallableApk(file: File): File {
65
+ if (!file.exists() || file.length() < MIN_APK_BYTES) {
66
+ throw IllegalArgumentException("APK file is missing or invalid: ${file.absolutePath}")
67
+ }
68
+
69
+ if (isAndroidApk(file)) {
70
+ return file
71
+ }
72
+
73
+ val unwrapped = unwrapApkFromZip(file)
74
+ if (unwrapped != null && isAndroidApk(unwrapped)) {
75
+ Log.i(TAG, "Unwrapped nested APK from zip: ${unwrapped.absolutePath}")
76
+ return unwrapped
77
+ }
78
+
79
+ throw IllegalArgumentException(
80
+ "Wrong package: file is not a valid Android APK (${file.name}). " +
81
+ "Expected an APK or a zip that contains one.",
82
+ )
83
+ }
84
+
85
+ fun isAndroidApk(file: File): Boolean = hasAndroidManifest(file)
86
+
87
+ private fun hasAndroidManifest(file: File): Boolean {
88
+ return try {
89
+ ZipFile(file).use { zip ->
90
+ val entries = zip.entries()
91
+ while (entries.hasMoreElements()) {
92
+ val name = entries.nextElement().name.replace('\\', '/').lowercase()
93
+ if (name == "androidmanifest.xml" || name.endsWith("/androidmanifest.xml")) {
94
+ return true
95
+ }
96
+ }
97
+ false
98
+ }
99
+ } catch (e: Exception) {
100
+ Log.w(TAG, "Not a readable zip/apk: ${file.absolutePath}", e)
101
+ false
102
+ }
103
+ }
104
+
105
+ private fun unwrapApkFromZip(zipFile: File): File? {
106
+ return try {
107
+ ZipFile(zipFile).use { zip ->
108
+ val apkEntries = mutableListOf<java.util.zip.ZipEntry>()
109
+ val entries = zip.entries()
110
+ while (entries.hasMoreElements()) {
111
+ val entry = entries.nextElement()
112
+ if (entry.isDirectory) continue
113
+ val name = entry.name.replace('\\', '/')
114
+ val base = name.substringAfterLast('/')
115
+ if (
116
+ name.split('/').contains("__MACOSX") ||
117
+ base.startsWith("._") ||
118
+ base == ".DS_Store"
119
+ ) {
120
+ continue
121
+ }
122
+ if (base.endsWith(".apk", ignoreCase = true)) {
123
+ apkEntries.add(entry)
124
+ }
125
+ }
126
+
127
+ if (apkEntries.isEmpty()) {
128
+ return null
129
+ }
130
+
131
+ val entry =
132
+ apkEntries.maxByOrNull { scoreApkEntry(it.name) }
133
+ ?: return null
134
+
135
+ val out =
136
+ File(
137
+ zipFile.parentFile,
138
+ "${zipFile.nameWithoutExtension}-unwrapped.apk",
139
+ )
140
+ if (out.exists()) {
141
+ out.delete()
142
+ }
143
+
144
+ zip.getInputStream(entry).use { input ->
145
+ BufferedInputStream(input).use { buffered ->
146
+ FileOutputStream(out).use { output ->
147
+ buffered.copyTo(output)
148
+ }
149
+ }
150
+ }
151
+
152
+ if (out.length() < MIN_APK_BYTES) {
153
+ out.delete()
154
+ return null
155
+ }
156
+
157
+ out
158
+ }
159
+ } catch (e: Exception) {
160
+ Log.e(TAG, "Failed to unwrap APK from ${zipFile.absolutePath}", e)
161
+ null
162
+ }
163
+ }
164
+
165
+ private fun scoreApkEntry(name: String): Int {
166
+ val lower = name.lowercase()
167
+ return when {
168
+ lower.contains("arm64-v8a") || lower.contains("arm64") || lower.contains("-v8a") -> 3
169
+ lower.contains("armeabi-v7a") || lower.contains("armeabi") || lower.contains("-v7a") -> 2
170
+ lower.contains("universal") -> 1
171
+ else -> 0
172
+ }
70
173
  }
71
174
 
72
175
  private fun needsInstallPermission(context: Context): Boolean =
@@ -108,14 +108,33 @@ class OTAUpdaterModule(private val reactContext: ReactApplicationContext) :
108
108
 
109
109
  @ReactMethod
110
110
  fun installApk(path: String, promise: Promise) {
111
- UiThreadUtil.runOnUiThread {
111
+ Thread {
112
112
  try {
113
- val result = OTAApkInstallHelper.install(reactContext, path)
114
- promise.resolve(result)
113
+ val prepared = OTAApkInstallHelper.prepareInstallableApk(File(path.removePrefix("file://")))
114
+ UiThreadUtil.runOnUiThread {
115
+ try {
116
+ val result = OTAApkInstallHelper.installPrepared(reactContext, prepared)
117
+ promise.resolve(result)
118
+ } catch (e: Exception) {
119
+ promise.reject("EINSTALL", e.message, e)
120
+ }
121
+ }
115
122
  } catch (e: Exception) {
116
123
  promise.reject("EINSTALL", e.message, e)
117
124
  }
118
- }
125
+ }.start()
126
+ }
127
+
128
+ @ReactMethod
129
+ fun isAndroidApkPackage(path: String, promise: Promise) {
130
+ Thread {
131
+ try {
132
+ val file = File(path.removePrefix("file://"))
133
+ promise.resolve(OTAApkInstallHelper.isAndroidApk(file))
134
+ } catch (e: Exception) {
135
+ promise.reject("EAPK", e.message, e)
136
+ }
137
+ }.start()
119
138
  }
120
139
 
121
140
  @ReactMethod
package/lib/sync.js CHANGED
@@ -427,12 +427,27 @@ async function isZipArchive(filePath) {
427
427
  }
428
428
  }
429
429
  async function shouldExtractApkZip(downloadedPath, packageFormat) {
430
- // APK files are themselves zip archives (PK magic). Only unzip when the
431
- // server said this package is a zip *of* an APK, or the download is named .zip.
432
430
  if (packageFormat === 'zip') {
433
431
  return true;
434
432
  }
435
- return downloadedPath.toLowerCase().endsWith('.zip');
433
+ if (downloadedPath.toLowerCase().endsWith('.zip')) {
434
+ return true;
435
+ }
436
+ // Format mismatch: zip-of-apk was saved as `.apk`. Real APKs are also zip
437
+ // containers, so only extract when AndroidManifest.xml is missing.
438
+ if (!(await isZipArchive(downloadedPath))) {
439
+ return false;
440
+ }
441
+ if (OTAUpdaterNative?.isAndroidApkPackage) {
442
+ try {
443
+ const isApk = await OTAUpdaterNative.isAndroidApkPackage(downloadedPath);
444
+ return !isApk;
445
+ }
446
+ catch {
447
+ return false;
448
+ }
449
+ }
450
+ return false;
436
451
  }
437
452
  async function shouldExtractZipPackage(downloadedPath, packageFormat) {
438
453
  if (packageFormat === 'zip') {
@@ -580,16 +595,25 @@ async function extractApkUpdate(label, zipPath, deviceAbi) {
580
595
  if (!extractedApk.toLowerCase().endsWith('.apk')) {
581
596
  throw new Error('Zip did not contain a valid APK');
582
597
  }
598
+ const extractedStat = await ReactNativeBlobUtil.fs.stat(extractedApk);
599
+ if (!extractedStat || extractedStat.size < 1024 * 100) {
600
+ throw new Error(`Extracted APK is too small (${extractedStat?.size ?? 0} bytes)`);
601
+ }
583
602
  // Never hand the zip (or a nested extract path) to the installer.
584
603
  if (extractedApk !== finalApkPath) {
585
604
  const finalExists = await ReactNativeBlobUtil.fs.exists(finalApkPath);
586
605
  if (finalExists) {
587
606
  await ReactNativeBlobUtil.fs.unlink(finalApkPath);
588
607
  }
589
- await ReactNativeBlobUtil.fs.cp(extractedApk, finalApkPath);
608
+ try {
609
+ await ReactNativeBlobUtil.fs.mv(extractedApk, finalApkPath);
610
+ }
611
+ catch {
612
+ await ReactNativeBlobUtil.fs.cp(extractedApk, finalApkPath);
613
+ }
590
614
  }
591
615
  const zipExists = await ReactNativeBlobUtil.fs.exists(zipPath);
592
- if (zipExists) {
616
+ if (zipExists && zipPath !== finalApkPath) {
593
617
  await ReactNativeBlobUtil.fs.unlink(zipPath);
594
618
  }
595
619
  // Drop extract clutter; keep only the installable APK.
@@ -598,6 +622,10 @@ async function extractApkUpdate(label, zipPath, deviceAbi) {
598
622
  if (!apkExists) {
599
623
  throw new Error('Failed to prepare APK for install');
600
624
  }
625
+ const finalStat = await ReactNativeBlobUtil.fs.stat(finalApkPath);
626
+ if (!finalStat || finalStat.size < 1024 * 100) {
627
+ throw new Error(`Prepared APK is too small (${finalStat?.size ?? 0} bytes)`);
628
+ }
601
629
  return finalApkPath;
602
630
  }
603
631
  function getBundleDirectory() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appsonair.ir/react-native",
3
- "version": "1.0.10",
3
+ "version": "1.0.11",
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",