@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.
- package/android/src/main/java/com/otaupdater/OTAApkInstallHelper.kt +131 -11
- package/android/src/main/java/com/otaupdater/OTAUpdaterModule.kt +23 -4
- package/android/src/main/java/com/otaupdater/OTAZipExtractHelper.kt +29 -14
- package/lib/index.d.ts +2 -0
- package/lib/index.js +5 -1
- package/lib/progress.d.ts +1 -1
- package/lib/progress.js +2 -1
- package/lib/sync.d.ts +25 -2
- package/lib/sync.js +152 -54
- package/lib/types.d.ts +41 -1
- package/lib/ui/OTAUpdateModal.d.ts +3 -8
- package/lib/ui/OTAUpdateModal.js +97 -7
- package/lib/ui/OTAUpdateProvider.d.ts +1 -1
- package/lib/ui/OTAUpdateProvider.js +22 -3
- package/lib/zipExtract.js +6 -0
- package/package.json +1 -1
|
@@ -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
|
|
20
|
-
|
|
21
|
-
|
|
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,
|
|
31
|
+
OTAUpdaterStorage.setPendingApkPath(reactContext, prepared.absolutePath)
|
|
27
32
|
openUnknownSourcesSettings(reactContext)
|
|
28
33
|
return "permission_required"
|
|
29
34
|
}
|
|
30
35
|
|
|
31
|
-
launchInstallIntent(reactContext,
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
111
|
+
Thread {
|
|
112
112
|
try {
|
|
113
|
-
val
|
|
114
|
-
|
|
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.
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
30
|
-
outFile.parentFile?.mkdirs()
|
|
31
|
-
FileOutputStream(outFile).use { output ->
|
|
32
|
-
zipInput.copyTo(output)
|
|
33
|
-
}
|
|
41
|
+
continue
|
|
34
42
|
}
|
|
35
43
|
|
|
36
|
-
|
|
37
|
-
entry
|
|
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
|
|
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
|
|
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
|
-
|
|
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' && (
|
|
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
|
-
|
|
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('
|
|
862
|
+
throw new Error('Wrong package: hash verification failed');
|
|
781
863
|
}
|
|
782
864
|
}
|
|
783
|
-
if (extension === 'apk' && (
|
|
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 ??
|
|
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,
|
|
943
|
+
showProgress(status, progress, statusMessage, {
|
|
853
944
|
showActions: false,
|
|
854
945
|
canSkip: false,
|
|
855
946
|
...extra,
|
|
856
947
|
});
|
|
857
948
|
}
|
|
858
949
|
}
|
|
859
|
-
|
|
860
|
-
|
|
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
|
-
|
|
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
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
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
|
-
|
|
1015
|
-
|
|
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
|
-
|
|
1031
|
-
|
|
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
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
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
|
-
|
|
1088
|
-
|
|
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 {
|
|
3
|
-
|
|
4
|
-
|
|
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;
|
package/lib/ui/OTAUpdateModal.js
CHANGED
|
@@ -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,
|
|
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
|
-
(
|
|
95
|
+
handleSkip();
|
|
66
96
|
else if (showActions && (isDeferredDone || isError)) {
|
|
67
|
-
(
|
|
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
|
-
{
|
|
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={
|
|
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={
|
|
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={
|
|
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
|
-
|
|
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);
|