@appsonair.ir/react-native 1.0.7 → 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,30 +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
- }
24
+ val prepared = prepareInstallableApk(File(normalizePath(path)))
25
+ return installPrepared(reactContext, prepared)
26
+ }
23
27
 
28
+ fun installPrepared(reactContext: ReactApplicationContext, prepared: File): String {
24
29
  if (needsInstallPermission(reactContext) &&
25
30
  !reactContext.packageManager.canRequestPackageInstalls()) {
26
- OTAUpdaterStorage.setPendingApkPath(reactContext, file.absolutePath)
31
+ OTAUpdaterStorage.setPendingApkPath(reactContext, prepared.absolutePath)
27
32
  openUnknownSourcesSettings(reactContext)
28
33
  return "permission_required"
29
34
  }
30
35
 
31
- launchInstallIntent(reactContext, file)
36
+ launchInstallIntent(reactContext, prepared)
32
37
  OTAUpdaterStorage.clearPendingApkPath(reactContext)
33
38
  return "started"
34
39
  }
@@ -42,14 +47,129 @@ object OTAApkInstallHelper {
42
47
  return
43
48
  }
44
49
 
45
- val file = File(path)
46
- 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)
47
56
  OTAUpdaterStorage.clearPendingApkPath(reactContext)
48
- return
49
57
  }
58
+ }
50
59
 
51
- launchInstallIntent(reactContext, file)
52
- 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
+ }
53
173
  }
54
174
 
55
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
@@ -1,9 +1,9 @@
1
1
  package com.otaupdater
2
2
 
3
+ import java.io.BufferedInputStream
3
4
  import java.io.File
4
- import java.io.FileInputStream
5
5
  import java.io.FileOutputStream
6
- import java.util.zip.ZipInputStream
6
+ import java.util.zip.ZipFile
7
7
 
8
8
  object OTAZipExtractHelper {
9
9
  fun extract(zipPath: String, destDirPath: String) {
@@ -19,28 +19,43 @@ object OTAZipExtractHelper {
19
19
  }
20
20
  destDir.mkdirs()
21
21
 
22
- ZipInputStream(FileInputStream(zipFile)).use { zipInput ->
23
- var entry = zipInput.nextEntry
24
- while (entry != null) {
25
- val outFile = safeOutputFile(destDir, entry.name)
22
+ // ZipFile handles Info-ZIP / macOS ditto archives more reliably than ZipInputStream.
23
+ ZipFile(zipFile).use { zip ->
24
+ val entries = zip.entries()
25
+ while (entries.hasMoreElements()) {
26
+ val entry = entries.nextElement()
27
+ val normalized = entry.name.replace('\\', '/').trimStart('/')
28
+ val baseName = normalized.substringAfterLast('/')
29
+ if (
30
+ normalized.split('/').contains("__MACOSX") ||
31
+ baseName.startsWith("._") ||
32
+ baseName == ".DS_Store"
33
+ ) {
34
+ continue
35
+ }
36
+
37
+ val outFile = safeOutputFile(destDir, normalized)
26
38
 
27
39
  if (entry.isDirectory) {
28
40
  outFile.mkdirs()
29
- } else {
30
- outFile.parentFile?.mkdirs()
31
- FileOutputStream(outFile).use { output ->
32
- zipInput.copyTo(output)
33
- }
41
+ continue
34
42
  }
35
43
 
36
- zipInput.closeEntry()
37
- entry = zipInput.nextEntry
44
+ outFile.parentFile?.mkdirs()
45
+ zip.getInputStream(entry).use { input ->
46
+ BufferedInputStream(input).use { buffered ->
47
+ FileOutputStream(outFile).use { output ->
48
+ buffered.copyTo(output)
49
+ }
50
+ }
51
+ }
38
52
  }
39
53
  }
40
54
  }
41
55
 
42
56
  private fun safeOutputFile(destDir: File, entryName: String): File {
43
- val outFile = File(destDir, entryName)
57
+ val normalized = entryName.replace('\\', '/').trimStart('/')
58
+ val outFile = File(destDir, normalized)
44
59
  val destPath = destDir.canonicalPath
45
60
  val outPath = outFile.canonicalPath
46
61
  if (outPath != destPath && !outPath.startsWith("$destPath${File.separator}")) {
package/lib/index.d.ts CHANGED
@@ -18,6 +18,8 @@ export { setUserInfo, clearUserInfo, getUserInfo } from './userInfo';
18
18
  export type { OTAUserInfo } from './userInfo';
19
19
  export { OTAUpdateModal } from './ui/OTAUpdateModal';
20
20
  export { OTAUpdateProvider } from './ui/OTAUpdateProvider';
21
+ export { formatBytes, getProgressState, subscribeProgress } from './progress';
22
+ export type { OTAProgressState } from './sync';
21
23
  export declare const OTAUpdater: {
22
24
  configure: typeof configure;
23
25
  getConfig: typeof getConfig;
package/lib/index.js CHANGED
@@ -17,7 +17,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
17
17
  return (mod && mod.__esModule) ? mod : { "default": mod };
18
18
  };
19
19
  Object.defineProperty(exports, "__esModule", { value: true });
20
- exports.OTAUpdater = exports.OTAUpdateProvider = exports.OTAUpdateModal = exports.getUserInfo = exports.clearUserInfo = exports.setUserInfo = exports.logEvent = exports.stopTelemetry = exports.startTelemetry = exports.ping = exports.respondToUpdatePrompt = exports.isSyncInProgress = exports.notifyAppReady = exports.getInstalledBundlePath = exports.clearUpdate = exports.resumePendingApkInstall = exports.installApk = exports.restartApp = exports.sync = exports.resolveUpdaterConfig = exports.isOtaConfigured = exports.getDeploymentKey = exports.configureFromProject = exports.DEFAULT_SERVER_URL = exports.getServerUrl = exports.getConfig = exports.configure = void 0;
20
+ exports.OTAUpdater = exports.subscribeProgress = exports.getProgressState = exports.formatBytes = exports.OTAUpdateProvider = exports.OTAUpdateModal = exports.getUserInfo = exports.clearUserInfo = exports.setUserInfo = exports.logEvent = exports.stopTelemetry = exports.startTelemetry = exports.ping = exports.respondToUpdatePrompt = exports.isSyncInProgress = exports.notifyAppReady = exports.getInstalledBundlePath = exports.clearUpdate = exports.resumePendingApkInstall = exports.installApk = exports.restartApp = exports.sync = exports.resolveUpdaterConfig = exports.isOtaConfigured = exports.getDeploymentKey = exports.configureFromProject = exports.DEFAULT_SERVER_URL = exports.getServerUrl = exports.getConfig = exports.configure = void 0;
21
21
  exports.getCurrentLabel = getCurrentLabel;
22
22
  exports.checkUpdate = checkUpdate;
23
23
  const react_native_device_info_1 = __importDefault(require("react-native-device-info"));
@@ -77,6 +77,10 @@ var OTAUpdateModal_1 = require("./ui/OTAUpdateModal");
77
77
  Object.defineProperty(exports, "OTAUpdateModal", { enumerable: true, get: function () { return OTAUpdateModal_1.OTAUpdateModal; } });
78
78
  var OTAUpdateProvider_1 = require("./ui/OTAUpdateProvider");
79
79
  Object.defineProperty(exports, "OTAUpdateProvider", { enumerable: true, get: function () { return OTAUpdateProvider_1.OTAUpdateProvider; } });
80
+ var progress_1 = require("./progress");
81
+ Object.defineProperty(exports, "formatBytes", { enumerable: true, get: function () { return progress_1.formatBytes; } });
82
+ Object.defineProperty(exports, "getProgressState", { enumerable: true, get: function () { return progress_1.getProgressState; } });
83
+ Object.defineProperty(exports, "subscribeProgress", { enumerable: true, get: function () { return progress_1.subscribeProgress; } });
80
84
  exports.OTAUpdater = {
81
85
  configure: config_1.configure,
82
86
  getConfig: config_1.getConfig,
package/lib/progress.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  export type { OTAProgressState } from './sync';
2
- export { defaultMessages, delay, getMessageForStatus, getModalTitle, getProgressState, hideProgress, subscribeProgress, } from './sync';
2
+ export { defaultMessages, delay, formatBytes, getMessageForStatus, getModalTitle, getProgressState, hideProgress, subscribeProgress, } from './sync';
package/lib/progress.js CHANGED
@@ -1,9 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.subscribeProgress = exports.hideProgress = exports.getProgressState = exports.getModalTitle = exports.getMessageForStatus = exports.delay = exports.defaultMessages = void 0;
3
+ exports.subscribeProgress = exports.hideProgress = exports.getProgressState = exports.getModalTitle = exports.getMessageForStatus = exports.formatBytes = exports.delay = exports.defaultMessages = void 0;
4
4
  var sync_1 = require("./sync");
5
5
  Object.defineProperty(exports, "defaultMessages", { enumerable: true, get: function () { return sync_1.defaultMessages; } });
6
6
  Object.defineProperty(exports, "delay", { enumerable: true, get: function () { return sync_1.delay; } });
7
+ Object.defineProperty(exports, "formatBytes", { enumerable: true, get: function () { return sync_1.formatBytes; } });
7
8
  Object.defineProperty(exports, "getMessageForStatus", { enumerable: true, get: function () { return sync_1.getMessageForStatus; } });
8
9
  Object.defineProperty(exports, "getModalTitle", { enumerable: true, get: function () { return sync_1.getModalTitle; } });
9
10
  Object.defineProperty(exports, "getProgressState", { enumerable: true, get: function () { return sync_1.getProgressState; } });
package/lib/sync.d.ts CHANGED
@@ -13,6 +13,22 @@ export interface OTAProgressState {
13
13
  showActions?: boolean;
14
14
  canSkip?: boolean;
15
15
  error?: string;
16
+ /** Whether this update is a JS bundle or native APK */
17
+ updateType?: 'bundle' | 'apk';
18
+ /** Target release app version (from server) */
19
+ appVersion?: string;
20
+ /** Target APK versionCode (from server) */
21
+ buildNumber?: number;
22
+ /** Currently installed native app version */
23
+ currentAppVersion?: string;
24
+ /** Currently installed native build / versionCode */
25
+ currentBuildNumber?: string;
26
+ /** Currently installed JS bundle label, if any */
27
+ currentLabel?: string;
28
+ /** Total download size in bytes (0 if unknown) */
29
+ totalBytes?: number;
30
+ /** Bytes downloaded so far */
31
+ downloadedBytes?: number;
16
32
  }
17
33
  type ProgressListener = (state: OTAProgressState) => void;
18
34
  export declare function getProgressState(): OTAProgressState;
@@ -20,6 +36,8 @@ export declare function subscribeProgress(listener: ProgressListener): () => voi
20
36
  export declare function hideProgress(): void;
21
37
  export declare const defaultMessages: {
22
38
  title: string;
39
+ titleBundle: string;
40
+ titleApk: string;
23
41
  checking: string;
24
42
  downloading: string;
25
43
  installing: string;
@@ -28,15 +46,20 @@ export declare const defaultMessages: {
28
46
  downloadInterrupted: string;
29
47
  upToDate: string;
30
48
  updateAvailable: string;
49
+ updateAvailableBundle: string;
50
+ updateAvailableApk: string;
31
51
  changelogTitle: string;
32
52
  updateNow: string;
33
53
  skip: string;
34
54
  dismiss: string;
35
55
  appliedNextLaunch: string;
36
56
  mandatoryHint: string;
57
+ versionBundle: string;
58
+ versionApk: string;
37
59
  };
38
60
  export declare function getMessageForStatus(status: SyncStatus, messages?: Partial<typeof defaultMessages>): string;
39
- export declare function getModalTitle(messages?: Partial<typeof defaultMessages>): string;
61
+ export declare function getModalTitle(messages?: Partial<typeof defaultMessages>, updateType?: 'bundle' | 'apk'): string;
62
+ export declare function formatBytes(bytes: number | undefined): string;
40
63
  export declare function delay(ms: number): Promise<void>;
41
64
  type UpdatePromptAction = 'update' | 'skip' | 'dismiss';
42
65
  /** Resolve the Update / Skip / Dismiss prompt shown by OTAUpdateModal. */
@@ -54,7 +77,7 @@ export declare function checkForUpdate(serverUrl: string, deploymentKey: string,
54
77
  export declare function getBundleReleaseDir(label: string): string;
55
78
  export declare function getBundleFilePath(label: string): string;
56
79
  export declare function getApkFilePath(label: string): string;
57
- export declare function downloadBundle(downloadUrl: string, deploymentKey: string, label: string, expectedSize?: number, onProgress?: (progress: 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): Promise<string>;
58
81
  export declare function verifyBundleHash(filePath: string, expectedHash: string): Promise<boolean>;
59
82
  type ProgressCallback = (status: SyncStatus, progress?: number) => void;
60
83
  export declare function isSyncInProgress(): boolean;
package/lib/sync.js CHANGED
@@ -42,6 +42,7 @@ exports.subscribeProgress = subscribeProgress;
42
42
  exports.hideProgress = hideProgress;
43
43
  exports.getMessageForStatus = getMessageForStatus;
44
44
  exports.getModalTitle = getModalTitle;
45
+ exports.formatBytes = formatBytes;
45
46
  exports.delay = delay;
46
47
  exports.respondToUpdatePrompt = respondToUpdatePrompt;
47
48
  exports.getStoredBundleMeta = getStoredBundleMeta;
@@ -105,6 +106,8 @@ function shouldShowModalForStatus(status) {
105
106
  }
106
107
  exports.defaultMessages = {
107
108
  title: 'به\u200cروزرسانی برنامه',
109
+ titleBundle: 'به\u200cروزرسانی محتوا',
110
+ titleApk: 'به\u200cروزرسانی نسخه برنامه',
108
111
  checking: 'در حال بررسی به\u200cروزرسانی...',
109
112
  downloading: 'در حال دانلود به\u200cروزرسانی...',
110
113
  installing: 'در حال نصب به\u200cروزرسانی...',
@@ -113,12 +116,16 @@ exports.defaultMessages = {
113
116
  downloadInterrupted: 'دانلود قطع شد. لطفاً دوباره تلاش کنید',
114
117
  upToDate: 'برنامه به\u200cروز است',
115
118
  updateAvailable: 'نسخه جدیدی در دسترس است',
119
+ updateAvailableBundle: 'به\u200cروزرسانی محتوا آماده است',
120
+ updateAvailableApk: 'نسخه جدید برنامه آماده نصب است',
116
121
  changelogTitle: 'تغییرات این نسخه',
117
122
  updateNow: 'به\u200cروزرسانی',
118
123
  skip: 'فعلاً نه',
119
124
  dismiss: 'باشه',
120
125
  appliedNextLaunch: 'به\u200cروزرسانی نصب شد و در اجرای بعدی برنامه اعمال می\u200cشود',
121
126
  mandatoryHint: 'این به\u200cروزرسانی الزامی است',
127
+ versionBundle: 'باندل',
128
+ versionApk: 'نسخه برنامه',
122
129
  };
123
130
  function getMessageForStatus(status, messages = {}) {
124
131
  const m = { ...exports.defaultMessages, ...messages };
@@ -141,10 +148,28 @@ function getMessageForStatus(status, messages = {}) {
141
148
  return m.upToDate;
142
149
  }
143
150
  }
144
- function getModalTitle(messages = {}) {
151
+ function getModalTitle(messages = {}, updateType) {
145
152
  const m = { ...exports.defaultMessages, ...messages };
153
+ if (updateType === 'apk') {
154
+ return m.titleApk ?? m.title;
155
+ }
156
+ if (updateType === 'bundle') {
157
+ return m.titleBundle ?? m.title;
158
+ }
146
159
  return m.title;
147
160
  }
161
+ function formatBytes(bytes) {
162
+ if (bytes == null || !Number.isFinite(bytes) || bytes <= 0) {
163
+ return '—';
164
+ }
165
+ if (bytes < 1024) {
166
+ return `${Math.round(bytes)} B`;
167
+ }
168
+ if (bytes < 1024 * 1024) {
169
+ return `${(bytes / 1024).toFixed(1)} KB`;
170
+ }
171
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
172
+ }
148
173
  function delay(ms) {
149
174
  return new Promise((resolve) => setTimeout(resolve, ms));
150
175
  }
@@ -401,6 +426,29 @@ async function isZipArchive(filePath) {
401
426
  return false;
402
427
  }
403
428
  }
429
+ async function shouldExtractApkZip(downloadedPath, packageFormat) {
430
+ if (packageFormat === 'zip') {
431
+ return true;
432
+ }
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;
451
+ }
404
452
  async function shouldExtractZipPackage(downloadedPath, packageFormat) {
405
453
  if (packageFormat === 'zip') {
406
454
  return true;
@@ -539,14 +587,46 @@ async function pickExtractedApk(dir, deviceAbi) {
539
587
  async function extractApkUpdate(label, zipPath, deviceAbi) {
540
588
  const ReactNativeBlobUtil = require('react-native-blob-util').default;
541
589
  const extractDir = getApkExtractDir(label);
590
+ const finalApkPath = getApkFilePath(label);
542
591
  await removePathRecursive(extractDir);
543
592
  await ReactNativeBlobUtil.fs.mkdir(extractDir);
544
593
  await (0, zipExtract_1.extractZipToDirectory)(zipPath, extractDir);
594
+ const extractedApk = await pickExtractedApk(extractDir, deviceAbi);
595
+ if (!extractedApk.toLowerCase().endsWith('.apk')) {
596
+ throw new Error('Zip did not contain a valid APK');
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
+ }
602
+ // Never hand the zip (or a nested extract path) to the installer.
603
+ if (extractedApk !== finalApkPath) {
604
+ const finalExists = await ReactNativeBlobUtil.fs.exists(finalApkPath);
605
+ if (finalExists) {
606
+ await ReactNativeBlobUtil.fs.unlink(finalApkPath);
607
+ }
608
+ try {
609
+ await ReactNativeBlobUtil.fs.mv(extractedApk, finalApkPath);
610
+ }
611
+ catch {
612
+ await ReactNativeBlobUtil.fs.cp(extractedApk, finalApkPath);
613
+ }
614
+ }
545
615
  const zipExists = await ReactNativeBlobUtil.fs.exists(zipPath);
546
- if (zipExists) {
616
+ if (zipExists && zipPath !== finalApkPath) {
547
617
  await ReactNativeBlobUtil.fs.unlink(zipPath);
548
618
  }
549
- return pickExtractedApk(extractDir, deviceAbi);
619
+ // Drop extract clutter; keep only the installable APK.
620
+ await removePathRecursive(extractDir);
621
+ const apkExists = await ReactNativeBlobUtil.fs.exists(finalApkPath);
622
+ if (!apkExists) {
623
+ throw new Error('Failed to prepare APK for install');
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
+ }
629
+ return finalApkPath;
550
630
  }
551
631
  function getBundleDirectory() {
552
632
  const ReactNativeBlobUtil = require('react-native-blob-util').default;
@@ -689,7 +769,7 @@ async function tryRecoverCompletedDownload(destPath, extension, totalBytes, expe
689
769
  else if (!sizeLooksComplete) {
690
770
  return null;
691
771
  }
692
- if (extension === 'apk' && (packageFormat === 'zip' || (await isZipArchive(destPath)))) {
772
+ if (extension === 'apk' && (await shouldExtractApkZip(destPath, packageFormat))) {
693
773
  return extractApkUpdate(label, destPath, deviceAbi);
694
774
  }
695
775
  if (extension === 'bundle' && (await shouldExtractZipPackage(destPath, packageFormat))) {
@@ -736,9 +816,11 @@ async function downloadBundle(downloadUrl, deploymentKey, label, expectedSize, o
736
816
  if (!onProgress)
737
817
  return;
738
818
  const resolvedTotal = total > 0 ? total : totalBytes;
739
- if (resolvedTotal <= 0)
819
+ if (resolvedTotal <= 0) {
820
+ onProgress(0, Math.max(received, 0), 0);
740
821
  return;
741
- onProgress(Math.min(Math.max(received / resolvedTotal, 0), 0.99));
822
+ }
823
+ onProgress(Math.min(Math.max(received / resolvedTotal, 0), 0.99), Math.max(received, 0), resolvedTotal);
742
824
  };
743
825
  let pollTimer = null;
744
826
  if (onProgress && totalBytes > 0) {
@@ -762,9 +844,6 @@ async function downloadBundle(downloadUrl, deploymentKey, label, expectedSize, o
762
844
  }
763
845
  try {
764
846
  const response = await task;
765
- if (onProgress) {
766
- onProgress(1);
767
- }
768
847
  const downloadedPath = String(response.path()).replace(/^file:\/\//, '');
769
848
  if (downloadedPath !== destPath) {
770
849
  await ReactNativeBlobUtil.fs.cp(downloadedPath, destPath);
@@ -774,13 +853,16 @@ async function downloadBundle(downloadUrl, deploymentKey, label, expectedSize, o
774
853
  if (!stat || stat.size < minSize) {
775
854
  throw new Error(`Downloaded file is too small (${stat?.size ?? 0} bytes)`);
776
855
  }
856
+ if (onProgress) {
857
+ onProgress(1, stat.size, totalBytes > 0 ? totalBytes : stat.size);
858
+ }
777
859
  if (expectedHash) {
778
860
  const valid = await verifyBundleHash(destPath, expectedHash);
779
861
  if (!valid) {
780
- throw new Error('Package hash verification failed');
862
+ throw new Error('Wrong package: hash verification failed');
781
863
  }
782
864
  }
783
- if (extension === 'apk' && (isApkZip || (await isZipArchive(destPath)))) {
865
+ if (extension === 'apk' && (await shouldExtractApkZip(destPath, packageFormat))) {
784
866
  return extractApkUpdate(label, destPath, deviceAbi);
785
867
  }
786
868
  if (extension === 'bundle' && (await shouldExtractZipPackage(destPath, packageFormat))) {
@@ -839,8 +921,17 @@ function reportProgress(showUI, messages, onProgress, status, progress = 0, extr
839
921
  status === types_1.SyncStatus.APK_INSTALL_PENDING) {
840
922
  return;
841
923
  }
924
+ const updateType = extra?.updateType;
925
+ let statusMessage = extra?.message ?? getMessageForStatus(status, messages);
926
+ if (!extra?.message && status === types_1.SyncStatus.UPDATE_AVAILABLE && updateType) {
927
+ const m = { ...exports.defaultMessages, ...messages };
928
+ statusMessage =
929
+ updateType === 'apk'
930
+ ? (m.updateAvailableApk ?? m.updateAvailable)
931
+ : (m.updateAvailableBundle ?? m.updateAvailable);
932
+ }
842
933
  if (status === types_1.SyncStatus.ERROR) {
843
- showProgress(status, progress, extra?.error ?? getMessageForStatus(status, messages), {
934
+ showProgress(status, progress, extra?.error ?? statusMessage, {
844
935
  error: extra?.error,
845
936
  showActions: true,
846
937
  canSkip: false,
@@ -849,24 +940,36 @@ function reportProgress(showUI, messages, onProgress, status, progress = 0, extr
849
940
  return;
850
941
  }
851
942
  if (shouldShowModalForStatus(status)) {
852
- showProgress(status, progress, extra?.message ?? getMessageForStatus(status, messages), {
943
+ showProgress(status, progress, statusMessage, {
853
944
  showActions: false,
854
945
  canSkip: false,
855
946
  ...extra,
856
947
  });
857
948
  }
858
949
  }
859
- async function promptForUpdate(showUI, messages, onProgress, update, installMode) {
860
- const changelog = getUpdateChangelog(update);
950
+ function buildUpdateProgressExtra(update, installMode, currentLabel) {
951
+ return {
952
+ label: update.label,
953
+ description: getUpdateChangelog(update),
954
+ isMandatory: update.isMandatory,
955
+ installMode,
956
+ updateType: update.updateType ?? 'bundle',
957
+ appVersion: update.appVersion,
958
+ buildNumber: update.buildNumber,
959
+ currentAppVersion: react_native_device_info_1.default.getVersion(),
960
+ currentBuildNumber: react_native_1.Platform.OS === 'android' ? react_native_device_info_1.default.getBuildNumber() : undefined,
961
+ currentLabel: currentLabel ?? undefined,
962
+ totalBytes: update.size && update.size > 0 ? update.size : undefined,
963
+ downloadedBytes: 0,
964
+ };
965
+ }
966
+ async function promptForUpdate(showUI, messages, onProgress, update, installMode, currentLabel) {
861
967
  const isMandatory = update.isMandatory === true;
862
968
  if (!showUI) {
863
969
  return 'update';
864
970
  }
865
971
  reportProgress(showUI, messages, onProgress, types_1.SyncStatus.UPDATE_AVAILABLE, 0, {
866
- label: update.label,
867
- description: changelog,
868
- isMandatory,
869
- installMode,
972
+ ...buildUpdateProgressExtra(update, installMode, currentLabel),
870
973
  showActions: true,
871
974
  canSkip: !isMandatory,
872
975
  });
@@ -963,7 +1066,7 @@ async function performSync(options = {}, onProgress, messages) {
963
1066
  else {
964
1067
  await clearSkippedUpdate();
965
1068
  }
966
- const decision = await promptForUpdate(showUI, messages, onProgress, update, installMode);
1069
+ const decision = await promptForUpdate(showUI, messages, onProgress, update, installMode, meta?.label);
967
1070
  if (decision === 'skip') {
968
1071
  await markUpdateSkipped(update.label);
969
1072
  if (showUI)
@@ -977,19 +1080,14 @@ async function performSync(options = {}, onProgress, messages) {
977
1080
  }
978
1081
  await clearSkippedUpdate();
979
1082
  if (update.updateType === 'apk') {
980
- return performApkSync(update, showUI, messages, onProgress, changelog);
1083
+ return performApkSync(update, showUI, messages, onProgress, changelog, meta?.label);
981
1084
  }
982
- reportProgress(showUI, messages, onProgress, types_1.SyncStatus.DOWNLOADING, 0, {
983
- label: update.label,
984
- description: changelog,
985
- isMandatory: update.isMandatory,
986
- installMode,
987
- });
988
- const bundlePath = await downloadBundle(update.downloadUrl, config.deploymentKey, update.label, update.size, (p) => reportProgress(showUI, messages, onProgress, types_1.SyncStatus.DOWNLOADING, p, {
989
- label: update.label,
990
- description: changelog,
991
- isMandatory: update.isMandatory,
992
- installMode,
1085
+ const progressExtra = buildUpdateProgressExtra(update, installMode, meta?.label);
1086
+ reportProgress(showUI, messages, onProgress, types_1.SyncStatus.DOWNLOADING, 0, progressExtra);
1087
+ const bundlePath = await downloadBundle(update.downloadUrl, config.deploymentKey, update.label, update.size, (p, received, total) => reportProgress(showUI, messages, onProgress, types_1.SyncStatus.DOWNLOADING, p, {
1088
+ ...progressExtra,
1089
+ downloadedBytes: received,
1090
+ totalBytes: total && total > 0 ? total : progressExtra.totalBytes,
993
1091
  }), 'bundle', undefined, update.packageFormat ?? 'bundle', update.packageHash);
994
1092
  onProgress?.(types_1.SyncStatus.INSTALLING, 1);
995
1093
  const ReactNativeBlobUtil = require('react-native-blob-util').default;
@@ -1011,10 +1109,8 @@ async function performSync(options = {}, onProgress, messages) {
1011
1109
  const shouldRestartNow = installMode === 'immediate' || update.isMandatory === true;
1012
1110
  if (shouldRestartNow) {
1013
1111
  reportProgress(showUI, messages, onProgress, types_1.SyncStatus.RESTARTING, 1, {
1014
- label: update.label,
1015
- description: changelog,
1016
- isMandatory: update.isMandatory,
1017
- installMode,
1112
+ ...progressExtra,
1113
+ downloadedBytes: progressExtra.totalBytes,
1018
1114
  });
1019
1115
  await markRestartPending();
1020
1116
  await delay(300);
@@ -1027,10 +1123,8 @@ async function performSync(options = {}, onProgress, messages) {
1027
1123
  }
1028
1124
  if (showUI) {
1029
1125
  reportProgress(showUI, messages, onProgress, types_1.SyncStatus.UPDATE_INSTALLED, 1, {
1030
- label: update.label,
1031
- description: changelog,
1032
- isMandatory: update.isMandatory,
1033
- installMode,
1126
+ ...progressExtra,
1127
+ downloadedBytes: progressExtra.totalBytes,
1034
1128
  showActions: true,
1035
1129
  canSkip: false,
1036
1130
  message: getMessageForStatus(types_1.SyncStatus.UPDATE_INSTALLED, messages),
@@ -1061,22 +1155,23 @@ async function performSync(options = {}, onProgress, messages) {
1061
1155
  };
1062
1156
  }
1063
1157
  }
1064
- async function performApkSync(update, showUI, messages, onProgress, changelog) {
1158
+ async function performApkSync(update, showUI, messages, onProgress, changelog, currentLabel) {
1065
1159
  if (!update.downloadUrl || !update.label || !update.packageHash) {
1066
1160
  return { status: types_1.SyncStatus.UP_TO_DATE };
1067
1161
  }
1068
1162
  const notes = changelog ?? getUpdateChangelog(update);
1163
+ const progressExtra = buildUpdateProgressExtra(update, undefined, currentLabel);
1164
+ const packageFormat = update.packageFormat ?? 'zip';
1069
1165
  try {
1070
- reportProgress(showUI, messages, onProgress, types_1.SyncStatus.DOWNLOADING, 0, {
1071
- label: update.label,
1072
- description: notes,
1073
- isMandatory: update.isMandatory,
1074
- });
1075
- const apkPath = await downloadBundle(update.downloadUrl, (0, config_1.getConfig)().deploymentKey, update.label, update.size, (p) => reportProgress(showUI, messages, onProgress, types_1.SyncStatus.DOWNLOADING, p, {
1076
- label: update.label,
1077
- description: notes,
1078
- isMandatory: update.isMandatory,
1079
- }), 'apk', update.deviceAbi, update.packageFormat ?? 'bundle', update.packageHash);
1166
+ reportProgress(showUI, messages, onProgress, types_1.SyncStatus.DOWNLOADING, 0, progressExtra);
1167
+ 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, {
1168
+ ...progressExtra,
1169
+ downloadedBytes: received,
1170
+ totalBytes: total && total > 0 ? total : progressExtra.totalBytes,
1171
+ }), 'apk', update.deviceAbi, packageFormat, update.packageHash);
1172
+ if (!apkPath.toLowerCase().endsWith('.apk')) {
1173
+ throw new Error('Wrong package: expected an APK after zip extract');
1174
+ }
1080
1175
  onProgress?.(types_1.SyncStatus.INSTALLING, 1);
1081
1176
  // Drop the OTA JS path before opening the installer so the new APK does not
1082
1177
  // keep loading the previous bundle on first launch after install.
@@ -1084,9 +1179,8 @@ async function performApkSync(update, showUI, messages, onProgress, changelog) {
1084
1179
  const installResult = await installApk(apkPath);
1085
1180
  if (installResult === 'permission_required') {
1086
1181
  reportProgress(showUI, messages, onProgress, types_1.SyncStatus.INSTALLING, 1, {
1087
- label: update.label,
1088
- description: notes,
1089
- isMandatory: update.isMandatory,
1182
+ ...progressExtra,
1183
+ downloadedBytes: progressExtra.totalBytes,
1090
1184
  });
1091
1185
  return {
1092
1186
  status: types_1.SyncStatus.APK_INSTALL_PENDING,
@@ -1106,6 +1200,7 @@ async function performApkSync(update, showUI, messages, onProgress, changelog) {
1106
1200
  catch (error) {
1107
1201
  const err = error instanceof Error ? error : new Error(String(error));
1108
1202
  reportProgress(showUI, messages, onProgress, types_1.SyncStatus.ERROR, 0, {
1203
+ ...progressExtra,
1109
1204
  error: err.message,
1110
1205
  showActions: true,
1111
1206
  canSkip: false,
@@ -1143,6 +1238,9 @@ async function clearUpdate() {
1143
1238
  await removePathRecursive(getBundleReleaseDir(meta.label));
1144
1239
  await cleanupPartialDownload(getBundleZipPath(meta.label));
1145
1240
  await cleanupPartialDownload(getLegacyBundleFilePath(meta.label));
1241
+ await cleanupPartialDownload(getApkFilePath(meta.label));
1242
+ await cleanupPartialDownload(getApkZipPath(meta.label));
1243
+ await removePathRecursive(getApkExtractDir(meta.label));
1146
1244
  }
1147
1245
  }
1148
1246
  async function notifyAppReady() {
package/lib/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from 'react';
1
+ import type { ComponentType, ReactNode } from 'react';
2
2
  /** When to apply a downloaded JS bundle update. */
3
3
  export type InstallMode = 'immediate' | 'next-launch'
4
4
  /** @deprecated use `next-launch` */
@@ -35,6 +35,8 @@ export interface OTAUpdaterConfig {
35
35
  }
36
36
  export interface OTAProgressMessages {
37
37
  title?: string;
38
+ titleBundle?: string;
39
+ titleApk?: string;
38
40
  checking?: string;
39
41
  downloading?: string;
40
42
  installing?: string;
@@ -43,12 +45,43 @@ export interface OTAProgressMessages {
43
45
  upToDate?: string;
44
46
  /** Prompt when an update is available */
45
47
  updateAvailable?: string;
48
+ updateAvailableBundle?: string;
49
+ updateAvailableApk?: string;
46
50
  changelogTitle?: string;
47
51
  updateNow?: string;
48
52
  skip?: string;
49
53
  dismiss?: string;
50
54
  appliedNextLaunch?: string;
51
55
  mandatoryHint?: string;
56
+ versionBundle?: string;
57
+ versionApk?: string;
58
+ }
59
+ export interface OTAUpdateUIProps {
60
+ visible: boolean;
61
+ status: SyncStatus;
62
+ progress: number;
63
+ message: string;
64
+ label?: string;
65
+ description?: string;
66
+ isMandatory?: boolean;
67
+ installMode?: InstallMode;
68
+ showActions?: boolean;
69
+ canSkip?: boolean;
70
+ error?: string;
71
+ updateType?: 'bundle' | 'apk';
72
+ appVersion?: string;
73
+ buildNumber?: number;
74
+ currentAppVersion?: string;
75
+ currentBuildNumber?: string;
76
+ currentLabel?: string;
77
+ totalBytes?: number;
78
+ downloadedBytes?: number;
79
+ title?: string;
80
+ fontFamily?: string;
81
+ messages?: OTAProgressMessages;
82
+ onUpdate?: () => void;
83
+ onSkip?: () => void;
84
+ onDismiss?: () => void;
52
85
  }
53
86
  export interface OTAUpdateProviderProps {
54
87
  children: ReactNode;
@@ -64,6 +97,13 @@ export interface OTAUpdateProviderProps {
64
97
  fontFamily?: string;
65
98
  /** Passed to sync() */
66
99
  syncOptions?: SyncOptions;
100
+ /**
101
+ * Replace the built-in update modal.
102
+ * Receive progress props + action handlers; return null to hide.
103
+ */
104
+ renderUpdateUI?: (props: OTAUpdateUIProps) => ReactNode;
105
+ /** Component alternative to renderUpdateUI */
106
+ UpdateComponent?: ComponentType<OTAUpdateUIProps>;
67
107
  }
68
108
  export interface UpdateCheckResult {
69
109
  updateAvailable: boolean;
@@ -1,9 +1,4 @@
1
1
  import React from 'react';
2
- import { defaultMessages, OTAProgressState } from '../sync';
3
- interface OTAUpdateModalProps extends OTAProgressState {
4
- title?: string;
5
- fontFamily?: string;
6
- messages?: Partial<typeof defaultMessages>;
7
- }
8
- export declare function OTAUpdateModal({ visible, status, progress, message, description, isMandatory, error, showActions, canSkip, title, fontFamily, messages, }: OTAUpdateModalProps): React.JSX.Element;
9
- export {};
2
+ import { OTAUpdateUIProps } from '../types';
3
+ export type OTAUpdateModalProps = OTAUpdateUIProps;
4
+ export declare function OTAUpdateModal({ visible, status, progress, message, description, isMandatory, error, showActions, canSkip, updateType, appVersion, buildNumber, currentAppVersion, currentBuildNumber, currentLabel, label, totalBytes, downloadedBytes, title, fontFamily, messages, onUpdate, onSkip, onDismiss, }: OTAUpdateModalProps): React.JSX.Element;
@@ -38,8 +38,17 @@ const react_1 = __importStar(require("react"));
38
38
  const react_native_1 = require("react-native");
39
39
  const sync_1 = require("../sync");
40
40
  const types_1 = require("../types");
41
- function OTAUpdateModal({ visible, status, progress, message, description, isMandatory, error, showActions, canSkip, title = sync_1.defaultMessages.title, fontFamily, messages, }) {
41
+ function OTAUpdateModal({ visible, status, progress, message, description, isMandatory, error, showActions, canSkip, updateType, appVersion, buildNumber, currentAppVersion, currentBuildNumber, currentLabel, label, totalBytes, downloadedBytes, title, fontFamily, messages, onUpdate, onSkip, onDismiss, }) {
42
42
  const copy = (0, react_1.useMemo)(() => ({ ...sync_1.defaultMessages, ...messages }), [messages]);
43
+ const resolvedTitle = (0, react_1.useMemo)(() => {
44
+ if (title)
45
+ return title;
46
+ if (updateType === 'apk')
47
+ return copy.titleApk ?? copy.title;
48
+ if (updateType === 'bundle')
49
+ return copy.titleBundle ?? copy.title;
50
+ return copy.title;
51
+ }, [title, updateType, copy]);
43
52
  const customFontStyle = (0, react_1.useMemo)(() => fontFamily
44
53
  ? {
45
54
  fontFamily,
@@ -60,19 +69,58 @@ function OTAUpdateModal({ visible, status, progress, message, description, isMan
60
69
  const showSpinner = status === types_1.SyncStatus.RESTARTING ||
61
70
  status === types_1.SyncStatus.INSTALLING ||
62
71
  (!showProgressBar && !isPrompt && !isDeferredDone && !isError);
72
+ const typeLabel = updateType === 'apk'
73
+ ? copy.versionApk
74
+ : updateType === 'bundle'
75
+ ? copy.versionBundle
76
+ : null;
77
+ const currentVersionText = updateType === 'apk'
78
+ ? [currentAppVersion, currentBuildNumber ? `(${currentBuildNumber})` : null]
79
+ .filter(Boolean)
80
+ .join(' ')
81
+ : currentLabel || currentAppVersion || null;
82
+ const nextVersionText = updateType === 'apk'
83
+ ? [appVersion, buildNumber != null ? `(${buildNumber})` : null]
84
+ .filter(Boolean)
85
+ .join(' ')
86
+ : label || appVersion || null;
87
+ const handleUpdate = () => (onUpdate ? onUpdate() : (0, sync_1.respondToUpdatePrompt)('update'));
88
+ const handleSkip = () => (onSkip ? onSkip() : (0, sync_1.respondToUpdatePrompt)('skip'));
89
+ const handleDismiss = () => onDismiss ? onDismiss() : (0, sync_1.respondToUpdatePrompt)('dismiss');
90
+ const downloaded = downloadedBytes ?? 0;
91
+ const total = totalBytes ?? 0;
92
+ const showSizes = showProgressBar && (total > 0 || downloaded > 0);
63
93
  return (<react_native_1.Modal visible transparent animationType="fade" statusBarTranslucent onRequestClose={() => {
64
94
  if (canSkip)
65
- (0, sync_1.respondToUpdatePrompt)('skip');
95
+ handleSkip();
66
96
  else if (showActions && (isDeferredDone || isError)) {
67
- (0, sync_1.respondToUpdatePrompt)('dismiss');
97
+ handleDismiss();
68
98
  }
69
99
  }}>
70
100
  <react_native_1.View style={styles.overlay}>
71
101
  <react_native_1.View style={styles.card}>
72
102
  <react_native_1.Text style={[styles.title, !fontFamily && styles.titleBold, textStyle]}>
73
- {title}
103
+ {resolvedTitle}
74
104
  </react_native_1.Text>
75
105
 
106
+ {typeLabel ? (<react_native_1.View style={styles.badge}>
107
+ <react_native_1.Text style={[styles.badgeText, textStyle]}>{typeLabel}</react_native_1.Text>
108
+ </react_native_1.View>) : null}
109
+
110
+ {(currentVersionText || nextVersionText) && !error ? (<react_native_1.View style={styles.versionBox}>
111
+ {currentVersionText ? (<react_native_1.Text style={[styles.versionLine, textStyle]}>
112
+ فعلی: {currentVersionText}
113
+ </react_native_1.Text>) : null}
114
+ {nextVersionText ? (<react_native_1.Text style={[
115
+ styles.versionLine,
116
+ styles.versionNext,
117
+ !fontFamily && styles.titleBold,
118
+ textStyle,
119
+ ]}>
120
+ جدید: {nextVersionText}
121
+ </react_native_1.Text>) : null}
122
+ </react_native_1.View>) : null}
123
+
76
124
  <react_native_1.Text style={[styles.message, textStyle]}>{error ?? message}</react_native_1.Text>
77
125
 
78
126
  {isMandatory && isPrompt ? (<react_native_1.Text style={[styles.mandatory, textStyle]}>{copy.mandatoryHint}</react_native_1.Text>) : null}
@@ -93,13 +141,20 @@ function OTAUpdateModal({ visible, status, progress, message, description, isMan
93
141
  <react_native_1.Text style={[styles.percent, !fontFamily && styles.percentBold, textStyle]}>
94
142
  {progressPercent}٪
95
143
  </react_native_1.Text>
144
+ {showSizes ? (<react_native_1.Text style={[styles.sizeText, textStyle]}>
145
+ {(0, sync_1.formatBytes)(downloaded)} / {(0, sync_1.formatBytes)(total > 0 ? total : undefined)}
146
+ </react_native_1.Text>) : null}
96
147
  </>) : null}
97
148
 
149
+ {!showProgressBar && total > 0 && (isPrompt || isError) ? (<react_native_1.Text style={[styles.sizeText, textStyle]}>
150
+ حجم دانلود: {(0, sync_1.formatBytes)(total)}
151
+ </react_native_1.Text>) : null}
152
+
98
153
  {showSpinner ? (<react_native_1.ActivityIndicator size="large" color="#2563eb" style={styles.spinner}/>) : null}
99
154
 
100
155
  {showActions ? (<react_native_1.View style={styles.actions}>
101
156
  {isPrompt ? (<>
102
- <react_native_1.Pressable style={[styles.button, styles.buttonPrimary]} onPress={() => (0, sync_1.respondToUpdatePrompt)('update')}>
157
+ <react_native_1.Pressable style={[styles.button, styles.buttonPrimary]} onPress={handleUpdate}>
103
158
  <react_native_1.Text style={[
104
159
  styles.buttonPrimaryText,
105
160
  !fontFamily && styles.titleBold,
@@ -108,10 +163,10 @@ function OTAUpdateModal({ visible, status, progress, message, description, isMan
108
163
  {copy.updateNow}
109
164
  </react_native_1.Text>
110
165
  </react_native_1.Pressable>
111
- {canSkip ? (<react_native_1.Pressable style={[styles.button, styles.buttonGhost]} onPress={() => (0, sync_1.respondToUpdatePrompt)('skip')}>
166
+ {canSkip ? (<react_native_1.Pressable style={[styles.button, styles.buttonGhost]} onPress={handleSkip}>
112
167
  <react_native_1.Text style={[styles.buttonGhostText, textStyle]}>{copy.skip}</react_native_1.Text>
113
168
  </react_native_1.Pressable>) : null}
114
- </>) : (<react_native_1.Pressable style={[styles.button, styles.buttonPrimary]} onPress={() => (0, sync_1.respondToUpdatePrompt)('dismiss')}>
169
+ </>) : (<react_native_1.Pressable style={[styles.button, styles.buttonPrimary]} onPress={handleDismiss}>
115
170
  <react_native_1.Text style={[
116
171
  styles.buttonPrimaryText,
117
172
  !fontFamily && styles.titleBold,
@@ -150,6 +205,35 @@ const styles = react_native_1.StyleSheet.create({
150
205
  titleBold: {
151
206
  fontWeight: '700',
152
207
  },
208
+ badge: {
209
+ alignSelf: 'center',
210
+ backgroundColor: '#eff6ff',
211
+ borderRadius: 999,
212
+ paddingHorizontal: 10,
213
+ paddingVertical: 4,
214
+ marginBottom: 8,
215
+ },
216
+ badgeText: {
217
+ fontSize: 12,
218
+ color: '#1d4ed8',
219
+ textAlign: 'center',
220
+ },
221
+ versionBox: {
222
+ backgroundColor: '#f9fafb',
223
+ borderRadius: 10,
224
+ paddingVertical: 8,
225
+ paddingHorizontal: 12,
226
+ marginBottom: 8,
227
+ gap: 4,
228
+ },
229
+ versionLine: {
230
+ fontSize: 13,
231
+ color: '#6b7280',
232
+ textAlign: 'center',
233
+ },
234
+ versionNext: {
235
+ color: '#111827',
236
+ },
153
237
  message: {
154
238
  fontSize: 15,
155
239
  color: '#374151',
@@ -211,6 +295,12 @@ const styles = react_native_1.StyleSheet.create({
211
295
  percentBold: {
212
296
  fontWeight: '600',
213
297
  },
298
+ sizeText: {
299
+ marginTop: 4,
300
+ fontSize: 12,
301
+ color: '#9ca3af',
302
+ textAlign: 'center',
303
+ },
214
304
  spinner: {
215
305
  marginTop: 12,
216
306
  alignSelf: 'center',
@@ -1,3 +1,3 @@
1
1
  import React from 'react';
2
2
  import { OTAUpdateProviderProps } from '../types';
3
- export declare function OTAUpdateProvider({ children, projectConfig, autoSync, skipInDev, messages, fontFamily, syncOptions, }: OTAUpdateProviderProps): React.JSX.Element;
3
+ export declare function OTAUpdateProvider({ children, projectConfig, autoSync, skipInDev, messages, fontFamily, syncOptions, renderUpdateUI, UpdateComponent, }: OTAUpdateProviderProps): React.JSX.Element;
@@ -43,12 +43,12 @@ const telemetry_1 = require("../telemetry");
43
43
  const OTAUpdateModal_1 = require("./OTAUpdateModal");
44
44
  const SYNC_DELAY_MS = 2500;
45
45
  const FOREGROUND_SYNC_DEBOUNCE_MS = 30000;
46
- function OTAUpdateProvider({ children, projectConfig, autoSync = true, skipInDev = true, messages, fontFamily, syncOptions, }) {
46
+ function OTAUpdateProvider({ children, projectConfig, autoSync = true, skipInDev = true, messages, fontFamily, syncOptions, renderUpdateUI, UpdateComponent, }) {
47
47
  const [progress, setProgress] = (0, react_1.useState)((0, progress_1.getProgressState)());
48
48
  const lastSyncAtRef = (0, react_1.useRef)(0);
49
49
  const foregroundTimerRef = (0, react_1.useRef)(null);
50
50
  const hasMountedSyncRef = (0, react_1.useRef)(false);
51
- const modalTitle = (0, progress_1.getModalTitle)(messages);
51
+ const modalTitle = (0, progress_1.getModalTitle)(messages, progress.updateType);
52
52
  (0, react_1.useEffect)(() => {
53
53
  if (projectConfig) {
54
54
  (0, projectConfig_1.configureFromProject)(projectConfig);
@@ -101,8 +101,27 @@ function OTAUpdateProvider({ children, projectConfig, autoSync = true, skipInDev
101
101
  };
102
102
  // eslint-disable-next-line react-hooks/exhaustive-deps
103
103
  }, []);
104
+ const uiProps = (0, react_1.useMemo)(() => ({
105
+ ...progress,
106
+ title: modalTitle,
107
+ fontFamily,
108
+ messages,
109
+ onUpdate: () => (0, sync_1.respondToUpdatePrompt)('update'),
110
+ onSkip: () => (0, sync_1.respondToUpdatePrompt)('skip'),
111
+ onDismiss: () => (0, sync_1.respondToUpdatePrompt)('dismiss'),
112
+ }), [progress, modalTitle, fontFamily, messages]);
113
+ let updateUI = null;
114
+ if (renderUpdateUI) {
115
+ updateUI = renderUpdateUI(uiProps);
116
+ }
117
+ else if (UpdateComponent) {
118
+ updateUI = <UpdateComponent {...uiProps}/>;
119
+ }
120
+ else {
121
+ updateUI = <OTAUpdateModal_1.OTAUpdateModal {...uiProps}/>;
122
+ }
104
123
  return (<>
105
124
  {children}
106
- <OTAUpdateModal_1.OTAUpdateModal {...progress} title={modalTitle} fontFamily={fontFamily} messages={messages}/>
125
+ {updateUI}
107
126
  </>);
108
127
  }
package/lib/zipExtract.js CHANGED
@@ -88,6 +88,12 @@ async function extractZipToDirectoryJs(zipPath, destDir) {
88
88
  await mkdirRecursive(`${destDir}/${normalized.slice(0, -1)}`);
89
89
  continue;
90
90
  }
91
+ const base = normalized.split('/').pop() ?? normalized;
92
+ if (normalized.split('/').includes('__MACOSX') ||
93
+ base.startsWith('._') ||
94
+ base === '.DS_Store') {
95
+ continue;
96
+ }
91
97
  const content = rawContent instanceof Uint8Array
92
98
  ? rawContent
93
99
  : new Uint8Array(rawContent);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appsonair.ir/react-native",
3
- "version": "1.0.7",
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",