react-native-custom-ota 0.0.0-stage → 1.0.0
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/README.md +214 -2
- package/RNCustomOTA.podspec +20 -0
- package/android/build.gradle +21 -0
- package/android/src/main/java/com/customota/CustomOTABundleResolver.kt +85 -0
- package/android/src/main/java/com/customota/OTAUpdaterModule.kt +152 -0
- package/android/src/main/java/com/customota/OTAUpdaterPackage.kt +22 -0
- package/bin/custom-ota.js +217 -0
- package/ios/CustomOTABundleResolver.swift +79 -0
- package/ios/OTAUpdater.m +19 -0
- package/ios/OTAUpdater.swift +105 -0
- package/lib/commonjs/hoc.js +64 -0
- package/lib/commonjs/index.js +32 -0
- package/lib/commonjs/manager.js +262 -0
- package/lib/commonjs/native.js +9 -0
- package/lib/commonjs/storage.js +48 -0
- package/lib/commonjs/types.js +23 -0
- package/lib/module/hoc.js +28 -0
- package/lib/module/index.js +16 -0
- package/lib/module/manager.js +255 -0
- package/lib/module/native.js +6 -0
- package/lib/module/storage.js +39 -0
- package/lib/module/types.js +20 -0
- package/lib/typescript/hoc.d.ts +3 -0
- package/lib/typescript/hoc.js +64 -0
- package/lib/typescript/index.d.ts +16 -0
- package/lib/typescript/index.js +32 -0
- package/lib/typescript/manager.d.ts +35 -0
- package/lib/typescript/manager.js +262 -0
- package/lib/typescript/native.d.ts +8 -0
- package/lib/typescript/native.js +9 -0
- package/lib/typescript/src/hoc.d.ts +3 -0
- package/lib/typescript/src/index.d.ts +15 -0
- package/lib/typescript/src/manager.d.ts +33 -0
- package/lib/typescript/src/native.d.ts +8 -0
- package/lib/typescript/src/storage.d.ts +8 -0
- package/lib/typescript/src/types.d.ts +54 -0
- package/lib/typescript/storage.d.ts +8 -0
- package/lib/typescript/storage.js +48 -0
- package/lib/typescript/types.d.ts +60 -0
- package/lib/typescript/types.js +23 -0
- package/package.json +49 -4
- package/react-native.config.js +11 -0
- package/src/hoc.tsx +34 -0
- package/src/index.ts +19 -0
- package/src/manager.ts +328 -0
- package/src/native.ts +17 -0
- package/src/storage.ts +45 -0
- package/src/types.ts +66 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.CustomOTAManager = void 0;
|
|
7
|
+
const react_native_1 = require("react-native");
|
|
8
|
+
const react_native_fs_1 = __importDefault(require("react-native-fs"));
|
|
9
|
+
const native_1 = require("./native");
|
|
10
|
+
const storage_1 = require("./storage");
|
|
11
|
+
const types_1 = require("./types");
|
|
12
|
+
class CustomOTAManager {
|
|
13
|
+
/**
|
|
14
|
+
* Đánh dấu bản build OTA hiện tại đã khởi động thành công.
|
|
15
|
+
* Xóa bản backup trước đó và tắt cờ pending.
|
|
16
|
+
*/
|
|
17
|
+
static async markReady() {
|
|
18
|
+
try {
|
|
19
|
+
const state = await (0, storage_1.readState)();
|
|
20
|
+
state.pending = false;
|
|
21
|
+
state.ready = true;
|
|
22
|
+
state.trial = false;
|
|
23
|
+
await (0, storage_1.writeState)(state);
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
console.warn("[CustomOTA] Error updating state in markReady:", e);
|
|
27
|
+
}
|
|
28
|
+
if (native_1.OTAUpdater?.markReady) {
|
|
29
|
+
try {
|
|
30
|
+
await native_1.OTAUpdater.markReady();
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
console.warn("[CustomOTA] Error calling native markReady:", e);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Khởi động lại ứng dụng để nạp ngay bản OTA mới.
|
|
39
|
+
*/
|
|
40
|
+
static async restartApp() {
|
|
41
|
+
if (native_1.OTAUpdater?.restart) {
|
|
42
|
+
await native_1.OTAUpdater.restart();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Lấy thông tin phiên bản OTA hiện hành trên máy.
|
|
47
|
+
*/
|
|
48
|
+
static async getUpdateMetadata() {
|
|
49
|
+
return (0, storage_1.readState)();
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Kiểm tra xem server có bản cập nhật mới hay không.
|
|
53
|
+
*/
|
|
54
|
+
static async checkForUpdate(options) {
|
|
55
|
+
await (0, storage_1.ensureOTADirs)();
|
|
56
|
+
const state = await (0, storage_1.readState)();
|
|
57
|
+
const platform = react_native_1.Platform.OS === "ios" ? "ios" : "android";
|
|
58
|
+
const nativeVersion = options.nativeVersion || "1.0.0";
|
|
59
|
+
const channel = options.channel || "production";
|
|
60
|
+
const params = new URLSearchParams({
|
|
61
|
+
platform,
|
|
62
|
+
nativeVersion,
|
|
63
|
+
otaVersion: state.currentVersion,
|
|
64
|
+
channel
|
|
65
|
+
});
|
|
66
|
+
if (options.appId) {
|
|
67
|
+
params.append("appId", options.appId);
|
|
68
|
+
}
|
|
69
|
+
if (options.apiKey) {
|
|
70
|
+
params.append("apiKey", options.apiKey);
|
|
71
|
+
}
|
|
72
|
+
const headers = {};
|
|
73
|
+
if (options.apiKey) {
|
|
74
|
+
headers["X-OTA-API-KEY"] = options.apiKey;
|
|
75
|
+
}
|
|
76
|
+
const response = await fetch(`${options.serverUrl.replace(/\/$/, "")}/ota/check?${params}`, {
|
|
77
|
+
headers
|
|
78
|
+
});
|
|
79
|
+
if (!response.ok) {
|
|
80
|
+
throw new Error(`OTA Check failed: HTTP ${response.status}`);
|
|
81
|
+
}
|
|
82
|
+
const rawData = await response.json();
|
|
83
|
+
if (!rawData.updateAvailable) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const targetVersion = rawData.otaVersion || rawData.version;
|
|
87
|
+
// Không báo cập nhật nếu phiên bản đã được cài đặt trên máy
|
|
88
|
+
if (targetVersion && state.currentVersion === targetVersion) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
// Không báo cập nhật nếu phiên bản này từng bị crash và tự rollback
|
|
92
|
+
if (targetVersion && state.failedVersions?.includes(targetVersion)) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const serverUrlClean = options.serverUrl.replace(/\/$/, "");
|
|
96
|
+
const rawUrl = rawData.downloadUrl || "";
|
|
97
|
+
const fullDownloadUrl = rawUrl.startsWith("http")
|
|
98
|
+
? rawUrl
|
|
99
|
+
: `${serverUrlClean}${rawUrl.startsWith("/") ? "" : "/"}${rawUrl}`;
|
|
100
|
+
const data = {
|
|
101
|
+
...rawData,
|
|
102
|
+
updateAvailable: true,
|
|
103
|
+
version: targetVersion,
|
|
104
|
+
otaVersion: targetVersion,
|
|
105
|
+
downloadUrl: fullDownloadUrl,
|
|
106
|
+
mandatory: Boolean(rawData.mandatory || rawData.isMandatory),
|
|
107
|
+
isMandatory: Boolean(rawData.mandatory || rawData.isMandatory)
|
|
108
|
+
};
|
|
109
|
+
return data;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Tải và cài đặt bản cập nhật.
|
|
113
|
+
*/
|
|
114
|
+
static async downloadAndInstall(update, onProgress, installMode) {
|
|
115
|
+
const version = update.otaVersion || update.version;
|
|
116
|
+
if (!update.downloadUrl || !version || !update.sha256) {
|
|
117
|
+
const missingFields = [
|
|
118
|
+
!update.downloadUrl && "downloadUrl",
|
|
119
|
+
!version && "version",
|
|
120
|
+
!update.sha256 && "sha256"
|
|
121
|
+
].filter(Boolean).join(", ");
|
|
122
|
+
throw new Error(`Invalid update package metadata: missing ${missingFields}`);
|
|
123
|
+
}
|
|
124
|
+
const zipPath = `${storage_1.TEMP_DIR}/update-${version}.zip`;
|
|
125
|
+
await react_native_fs_1.default.unlink(zipPath).catch(() => { });
|
|
126
|
+
// Download zip file
|
|
127
|
+
const downloadRes = react_native_fs_1.default.downloadFile({
|
|
128
|
+
fromUrl: update.downloadUrl.startsWith("http") ? update.downloadUrl : `${update.downloadUrl}`,
|
|
129
|
+
toFile: zipPath,
|
|
130
|
+
progressDivider: 1,
|
|
131
|
+
progress: ({ bytesWritten, contentLength }) => {
|
|
132
|
+
if (contentLength > 0 && onProgress) {
|
|
133
|
+
onProgress(bytesWritten / contentLength);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
const res = await downloadRes.promise;
|
|
138
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
139
|
+
throw new Error(`Download failed with status ${res.statusCode}`);
|
|
140
|
+
}
|
|
141
|
+
// SHA-256 verification
|
|
142
|
+
const actualHash = await react_native_fs_1.default.hash(zipPath, "sha256");
|
|
143
|
+
if (actualHash.toLowerCase() !== update.sha256.toLowerCase()) {
|
|
144
|
+
await react_native_fs_1.default.unlink(zipPath).catch(() => { });
|
|
145
|
+
throw new Error(`SHA-256 Checksum mismatch! Expected ${update.sha256}, got ${actualHash}`);
|
|
146
|
+
}
|
|
147
|
+
// Unpack temp directory
|
|
148
|
+
const tempDir = `${storage_1.TEMP_DIR}/${version}`;
|
|
149
|
+
await react_native_fs_1.default.unlink(tempDir).catch(() => { });
|
|
150
|
+
await react_native_fs_1.default.mkdir(tempDir);
|
|
151
|
+
await native_1.OTAUpdater.unzip(zipPath, tempDir);
|
|
152
|
+
const bundleName = react_native_1.Platform.OS === "ios" ? "main.jsbundle" : "index.android.bundle";
|
|
153
|
+
const bundleFile = `${tempDir}/${bundleName}`;
|
|
154
|
+
if (!(await react_native_fs_1.default.exists(bundleFile))) {
|
|
155
|
+
throw new Error(`Bundle missing after unzip: ${bundleName}`);
|
|
156
|
+
}
|
|
157
|
+
// Atomic move & backup
|
|
158
|
+
await react_native_fs_1.default.unlink(storage_1.BACKUP_DIR).catch(() => { });
|
|
159
|
+
if (await react_native_fs_1.default.exists(storage_1.CURRENT_DIR)) {
|
|
160
|
+
await react_native_fs_1.default.moveFile(storage_1.CURRENT_DIR, storage_1.BACKUP_DIR);
|
|
161
|
+
}
|
|
162
|
+
await react_native_fs_1.default.moveFile(tempDir, storage_1.CURRENT_DIR);
|
|
163
|
+
// Save state
|
|
164
|
+
const oldState = await (0, storage_1.readState)();
|
|
165
|
+
await (0, storage_1.writeState)({
|
|
166
|
+
currentVersion: version,
|
|
167
|
+
previousVersion: oldState.currentVersion !== "0.0.0" ? oldState.currentVersion : null,
|
|
168
|
+
pending: true,
|
|
169
|
+
ready: false,
|
|
170
|
+
trial: false,
|
|
171
|
+
failedVersions: oldState.failedVersions || []
|
|
172
|
+
});
|
|
173
|
+
await react_native_fs_1.default.unlink(zipPath).catch(() => { });
|
|
174
|
+
if (installMode === types_1.InstallMode.IMMEDIATE) {
|
|
175
|
+
await this.restartApp();
|
|
176
|
+
}
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Phương thức đồng bộ tự động như CodePush.sync()
|
|
181
|
+
*/
|
|
182
|
+
static async sync(options, syncStatusCallback, downloadProgressCallback) {
|
|
183
|
+
try {
|
|
184
|
+
// 1. Tự động markReady khi khởi động
|
|
185
|
+
await this.markReady();
|
|
186
|
+
// 2. Kiểm tra update
|
|
187
|
+
const update = await this.checkForUpdate({
|
|
188
|
+
serverUrl: options.serverUrl,
|
|
189
|
+
appId: options.appId,
|
|
190
|
+
apiKey: options.apiKey,
|
|
191
|
+
nativeVersion: options.nativeVersion,
|
|
192
|
+
channel: options.channel
|
|
193
|
+
});
|
|
194
|
+
if (!update || !update.updateAvailable) {
|
|
195
|
+
syncStatusCallback?.(types_1.SyncStatus.UP_TO_DATE);
|
|
196
|
+
return types_1.SyncStatus.UP_TO_DATE;
|
|
197
|
+
}
|
|
198
|
+
syncStatusCallback?.(types_1.SyncStatus.UPDATE_AVAILABLE);
|
|
199
|
+
const proceedWithInstall = async () => {
|
|
200
|
+
syncStatusCallback?.(types_1.SyncStatus.DOWNLOADING_PACKAGE);
|
|
201
|
+
const serverUrlClean = options.serverUrl.replace(/\/$/, "");
|
|
202
|
+
const fullDownloadUrl = update.downloadUrl?.startsWith("http")
|
|
203
|
+
? update.downloadUrl
|
|
204
|
+
: `${serverUrlClean}${update.downloadUrl}`;
|
|
205
|
+
await this.downloadAndInstall({ ...update, downloadUrl: fullDownloadUrl }, downloadProgressCallback);
|
|
206
|
+
syncStatusCallback?.(types_1.SyncStatus.INSTALLING_UPDATE);
|
|
207
|
+
syncStatusCallback?.(types_1.SyncStatus.UPDATE_INSTALLED);
|
|
208
|
+
const effectiveInstallMode = (update.mandatory && options.mandatoryInstallMode)
|
|
209
|
+
? options.mandatoryInstallMode
|
|
210
|
+
: (options.installMode || types_1.InstallMode.ON_NEXT_RESTART);
|
|
211
|
+
if (effectiveInstallMode === types_1.InstallMode.ON_NEXT_RESTART) {
|
|
212
|
+
return types_1.SyncStatus.UPDATE_INSTALLED;
|
|
213
|
+
}
|
|
214
|
+
// Restart immediate
|
|
215
|
+
await this.restartApp();
|
|
216
|
+
return types_1.SyncStatus.UPDATE_INSTALLED;
|
|
217
|
+
};
|
|
218
|
+
// 3. Nếu cấu hình hiển thị Update Dialog
|
|
219
|
+
if (options.updateDialog) {
|
|
220
|
+
const dialogOpts = typeof options.updateDialog === "object"
|
|
221
|
+
? { ...this.defaultDialog, ...options.updateDialog }
|
|
222
|
+
: this.defaultDialog;
|
|
223
|
+
const isMandatory = Boolean(update.mandatory);
|
|
224
|
+
const message = isMandatory
|
|
225
|
+
? `${dialogOpts.mandatoryUpdateMessage}\n\nPhiên bản: ${update.otaVersion}\n${update.releaseNotes || ""}`
|
|
226
|
+
: `${dialogOpts.optionalUpdateMessage}\n\nPhiên bản: ${update.otaVersion}\n${update.releaseNotes || ""}`;
|
|
227
|
+
const buttons = [];
|
|
228
|
+
if (!isMandatory) {
|
|
229
|
+
buttons.push({
|
|
230
|
+
text: dialogOpts.optionalIgnoreButtonLabel,
|
|
231
|
+
style: "cancel"
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
buttons.push({
|
|
235
|
+
text: dialogOpts.optionalInstallButtonLabel,
|
|
236
|
+
onPress: () => {
|
|
237
|
+
proceedWithInstall();
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
react_native_1.Alert.alert(dialogOpts.title || "Cập nhật ứng dụng", message, buttons, {
|
|
241
|
+
cancelable: !isMandatory
|
|
242
|
+
});
|
|
243
|
+
return types_1.SyncStatus.UPDATE_AVAILABLE;
|
|
244
|
+
}
|
|
245
|
+
// Tự động tải ngầm không hiện dialog
|
|
246
|
+
return await proceedWithInstall();
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
console.error("[react-native-custom-ota] Sync error:", error);
|
|
250
|
+
syncStatusCallback?.(types_1.SyncStatus.ERROR);
|
|
251
|
+
return types_1.SyncStatus.ERROR;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
exports.CustomOTAManager = CustomOTAManager;
|
|
256
|
+
CustomOTAManager.defaultDialog = {
|
|
257
|
+
title: "Có bản cập nhật mới",
|
|
258
|
+
optionalUpdateMessage: "Đã có bản cập nhật ứng dụng mới. Bạn có muốn cài đặt ngay bây giờ?",
|
|
259
|
+
mandatoryUpdateMessage: "Đã có bản cập nhật bắt buộc. Vui lòng cài đặt để tiếp tục sử dụng.",
|
|
260
|
+
optionalInstallButtonLabel: "Cập nhật",
|
|
261
|
+
optionalIgnoreButtonLabel: "Để sau"
|
|
262
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OTAUpdater = void 0;
|
|
4
|
+
const react_native_1 = require("react-native");
|
|
5
|
+
exports.OTAUpdater = react_native_1.NativeModules.OTAUpdater;
|
|
6
|
+
if (!exports.OTAUpdater) {
|
|
7
|
+
console.warn("[react-native-custom-ota] NativeModule.OTAUpdater is null! " +
|
|
8
|
+
"Make sure you have linked the library and rebuilt the native binary.");
|
|
9
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.TEMP_DIR = exports.BACKUP_DIR = exports.CURRENT_DIR = exports.OTA_ROOT = void 0;
|
|
7
|
+
exports.ensureOTADirs = ensureOTADirs;
|
|
8
|
+
exports.readState = readState;
|
|
9
|
+
exports.writeState = writeState;
|
|
10
|
+
const react_native_fs_1 = __importDefault(require("react-native-fs"));
|
|
11
|
+
exports.OTA_ROOT = `${react_native_fs_1.default.DocumentDirectoryPath}/ota`;
|
|
12
|
+
exports.CURRENT_DIR = `${exports.OTA_ROOT}/current`;
|
|
13
|
+
exports.BACKUP_DIR = `${exports.OTA_ROOT}/backup`;
|
|
14
|
+
exports.TEMP_DIR = `${exports.OTA_ROOT}/temp`;
|
|
15
|
+
const STATE_FILE = `${exports.OTA_ROOT}/state.json`;
|
|
16
|
+
async function ensureOTADirs() {
|
|
17
|
+
await react_native_fs_1.default.mkdir(exports.OTA_ROOT).catch(() => { });
|
|
18
|
+
await react_native_fs_1.default.mkdir(exports.TEMP_DIR).catch(() => { });
|
|
19
|
+
}
|
|
20
|
+
async function readState() {
|
|
21
|
+
await ensureOTADirs();
|
|
22
|
+
if (!(await react_native_fs_1.default.exists(STATE_FILE))) {
|
|
23
|
+
return {
|
|
24
|
+
currentVersion: "0.0.0",
|
|
25
|
+
previousVersion: null,
|
|
26
|
+
pending: false,
|
|
27
|
+
ready: true,
|
|
28
|
+
failedVersions: []
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const content = await react_native_fs_1.default.readFile(STATE_FILE, "utf8");
|
|
33
|
+
return JSON.parse(content);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return {
|
|
37
|
+
currentVersion: "0.0.0",
|
|
38
|
+
previousVersion: null,
|
|
39
|
+
pending: false,
|
|
40
|
+
ready: true,
|
|
41
|
+
failedVersions: []
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function writeState(state) {
|
|
46
|
+
await ensureOTADirs();
|
|
47
|
+
await react_native_fs_1.default.writeFile(STATE_FILE, JSON.stringify(state, null, 2), "utf8");
|
|
48
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.InstallMode = exports.CheckFrequency = exports.SyncStatus = void 0;
|
|
4
|
+
var SyncStatus;
|
|
5
|
+
(function (SyncStatus) {
|
|
6
|
+
SyncStatus[SyncStatus["UP_TO_DATE"] = 0] = "UP_TO_DATE";
|
|
7
|
+
SyncStatus[SyncStatus["UPDATE_AVAILABLE"] = 1] = "UPDATE_AVAILABLE";
|
|
8
|
+
SyncStatus[SyncStatus["DOWNLOADING_PACKAGE"] = 2] = "DOWNLOADING_PACKAGE";
|
|
9
|
+
SyncStatus[SyncStatus["INSTALLING_UPDATE"] = 3] = "INSTALLING_UPDATE";
|
|
10
|
+
SyncStatus[SyncStatus["UPDATE_INSTALLED"] = 4] = "UPDATE_INSTALLED";
|
|
11
|
+
SyncStatus[SyncStatus["ERROR"] = -1] = "ERROR";
|
|
12
|
+
})(SyncStatus || (exports.SyncStatus = SyncStatus = {}));
|
|
13
|
+
var CheckFrequency;
|
|
14
|
+
(function (CheckFrequency) {
|
|
15
|
+
CheckFrequency["ON_APP_START"] = "ON_APP_START";
|
|
16
|
+
CheckFrequency["ON_APP_RESUME"] = "ON_APP_RESUME";
|
|
17
|
+
CheckFrequency["MANUAL"] = "MANUAL";
|
|
18
|
+
})(CheckFrequency || (exports.CheckFrequency = CheckFrequency = {}));
|
|
19
|
+
var InstallMode;
|
|
20
|
+
(function (InstallMode) {
|
|
21
|
+
InstallMode["IMMEDIATE"] = "IMMEDIATE";
|
|
22
|
+
InstallMode["ON_NEXT_RESTART"] = "ON_NEXT_RESTART";
|
|
23
|
+
})(InstallMode || (exports.InstallMode = InstallMode = {}));
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import React, { useEffect } from "react";
|
|
2
|
+
import { AppState } from "react-native";
|
|
3
|
+
import { CustomOTAManager } from "./manager";
|
|
4
|
+
import { CheckFrequency } from "./types";
|
|
5
|
+
export function withCustomOTA(options) {
|
|
6
|
+
return function (WrappedComponent) {
|
|
7
|
+
return function CustomOTAWrapper(props) {
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
// Tự động kiểm tra cập nhật khi app khởi động
|
|
10
|
+
if (options.checkFrequency !== CheckFrequency.MANUAL) {
|
|
11
|
+
CustomOTAManager.sync(options);
|
|
12
|
+
}
|
|
13
|
+
// Nếu bật kiểm tra khi App Resume từ background
|
|
14
|
+
if (options.checkFrequency === CheckFrequency.ON_APP_RESUME) {
|
|
15
|
+
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
|
16
|
+
if (nextAppState === "active") {
|
|
17
|
+
CustomOTAManager.sync(options);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
return () => {
|
|
21
|
+
subscription.remove();
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
}, []);
|
|
25
|
+
return React.createElement(WrappedComponent, { ...props });
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { CustomOTAManager } from "./manager";
|
|
2
|
+
import { withCustomOTA } from "./hoc";
|
|
3
|
+
export * from "./types";
|
|
4
|
+
export * from "./manager";
|
|
5
|
+
export * from "./hoc";
|
|
6
|
+
export * from "./storage";
|
|
7
|
+
const CustomOTA = {
|
|
8
|
+
sync: CustomOTAManager.sync.bind(CustomOTAManager),
|
|
9
|
+
checkForUpdate: CustomOTAManager.checkForUpdate.bind(CustomOTAManager),
|
|
10
|
+
downloadAndInstall: CustomOTAManager.downloadAndInstall.bind(CustomOTAManager),
|
|
11
|
+
restartApp: CustomOTAManager.restartApp.bind(CustomOTAManager),
|
|
12
|
+
getUpdateMetadata: CustomOTAManager.getUpdateMetadata.bind(CustomOTAManager),
|
|
13
|
+
markReady: CustomOTAManager.markReady.bind(CustomOTAManager),
|
|
14
|
+
wrap: withCustomOTA
|
|
15
|
+
};
|
|
16
|
+
export default CustomOTA;
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { Alert, Platform } from "react-native";
|
|
2
|
+
import RNFS from "react-native-fs";
|
|
3
|
+
import { OTAUpdater } from "./native";
|
|
4
|
+
import { BACKUP_DIR, CURRENT_DIR, TEMP_DIR, ensureOTADirs, readState, writeState } from "./storage";
|
|
5
|
+
import { InstallMode, SyncStatus } from "./types";
|
|
6
|
+
export class CustomOTAManager {
|
|
7
|
+
/**
|
|
8
|
+
* Đánh dấu bản build OTA hiện tại đã khởi động thành công.
|
|
9
|
+
* Xóa bản backup trước đó và tắt cờ pending.
|
|
10
|
+
*/
|
|
11
|
+
static async markReady() {
|
|
12
|
+
try {
|
|
13
|
+
const state = await readState();
|
|
14
|
+
state.pending = false;
|
|
15
|
+
state.ready = true;
|
|
16
|
+
state.trial = false;
|
|
17
|
+
await writeState(state);
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
console.warn("[CustomOTA] Error updating state in markReady:", e);
|
|
21
|
+
}
|
|
22
|
+
if (OTAUpdater?.markReady) {
|
|
23
|
+
try {
|
|
24
|
+
await OTAUpdater.markReady();
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
console.warn("[CustomOTA] Error calling native markReady:", e);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Khởi động lại ứng dụng để nạp ngay bản OTA mới.
|
|
33
|
+
*/
|
|
34
|
+
static async restartApp() {
|
|
35
|
+
if (OTAUpdater?.restart) {
|
|
36
|
+
await OTAUpdater.restart();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Lấy thông tin phiên bản OTA hiện hành trên máy.
|
|
41
|
+
*/
|
|
42
|
+
static async getUpdateMetadata() {
|
|
43
|
+
return readState();
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Kiểm tra xem server có bản cập nhật mới hay không.
|
|
47
|
+
*/
|
|
48
|
+
static async checkForUpdate(options) {
|
|
49
|
+
await ensureOTADirs();
|
|
50
|
+
const state = await readState();
|
|
51
|
+
const platform = Platform.OS === "ios" ? "ios" : "android";
|
|
52
|
+
const nativeVersion = options.nativeVersion || "1.0.0";
|
|
53
|
+
const channel = options.channel || "production";
|
|
54
|
+
const params = new URLSearchParams({
|
|
55
|
+
platform,
|
|
56
|
+
nativeVersion,
|
|
57
|
+
otaVersion: state.currentVersion,
|
|
58
|
+
channel
|
|
59
|
+
});
|
|
60
|
+
if (options.appId) {
|
|
61
|
+
params.append("appId", options.appId);
|
|
62
|
+
}
|
|
63
|
+
if (options.apiKey) {
|
|
64
|
+
params.append("apiKey", options.apiKey);
|
|
65
|
+
}
|
|
66
|
+
const headers = {};
|
|
67
|
+
if (options.apiKey) {
|
|
68
|
+
headers["X-OTA-API-KEY"] = options.apiKey;
|
|
69
|
+
}
|
|
70
|
+
const response = await fetch(`${options.serverUrl.replace(/\/$/, "")}/ota/check?${params}`, {
|
|
71
|
+
headers
|
|
72
|
+
});
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
throw new Error(`OTA Check failed: HTTP ${response.status}`);
|
|
75
|
+
}
|
|
76
|
+
const rawData = await response.json();
|
|
77
|
+
if (!rawData.updateAvailable) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const targetVersion = rawData.otaVersion || rawData.version;
|
|
81
|
+
// Không báo cập nhật nếu phiên bản đã được cài đặt trên máy
|
|
82
|
+
if (targetVersion && state.currentVersion === targetVersion) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
// Không báo cập nhật nếu phiên bản này từng bị crash và tự rollback
|
|
86
|
+
if (targetVersion && state.failedVersions?.includes(targetVersion)) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
const serverUrlClean = options.serverUrl.replace(/\/$/, "");
|
|
90
|
+
const rawUrl = rawData.downloadUrl || "";
|
|
91
|
+
const fullDownloadUrl = rawUrl.startsWith("http")
|
|
92
|
+
? rawUrl
|
|
93
|
+
: `${serverUrlClean}${rawUrl.startsWith("/") ? "" : "/"}${rawUrl}`;
|
|
94
|
+
const data = {
|
|
95
|
+
...rawData,
|
|
96
|
+
updateAvailable: true,
|
|
97
|
+
version: targetVersion,
|
|
98
|
+
otaVersion: targetVersion,
|
|
99
|
+
downloadUrl: fullDownloadUrl,
|
|
100
|
+
mandatory: Boolean(rawData.mandatory || rawData.isMandatory),
|
|
101
|
+
isMandatory: Boolean(rawData.mandatory || rawData.isMandatory)
|
|
102
|
+
};
|
|
103
|
+
return data;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Tải và cài đặt bản cập nhật.
|
|
107
|
+
*/
|
|
108
|
+
static async downloadAndInstall(update, onProgress, installMode) {
|
|
109
|
+
const version = update.otaVersion || update.version;
|
|
110
|
+
if (!update.downloadUrl || !version || !update.sha256) {
|
|
111
|
+
const missingFields = [
|
|
112
|
+
!update.downloadUrl && "downloadUrl",
|
|
113
|
+
!version && "version",
|
|
114
|
+
!update.sha256 && "sha256"
|
|
115
|
+
].filter(Boolean).join(", ");
|
|
116
|
+
throw new Error(`Invalid update package metadata: missing ${missingFields}`);
|
|
117
|
+
}
|
|
118
|
+
const zipPath = `${TEMP_DIR}/update-${version}.zip`;
|
|
119
|
+
await RNFS.unlink(zipPath).catch(() => { });
|
|
120
|
+
// Download zip file
|
|
121
|
+
const downloadRes = RNFS.downloadFile({
|
|
122
|
+
fromUrl: update.downloadUrl.startsWith("http") ? update.downloadUrl : `${update.downloadUrl}`,
|
|
123
|
+
toFile: zipPath,
|
|
124
|
+
progressDivider: 1,
|
|
125
|
+
progress: ({ bytesWritten, contentLength }) => {
|
|
126
|
+
if (contentLength > 0 && onProgress) {
|
|
127
|
+
onProgress(bytesWritten / contentLength);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
const res = await downloadRes.promise;
|
|
132
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
133
|
+
throw new Error(`Download failed with status ${res.statusCode}`);
|
|
134
|
+
}
|
|
135
|
+
// SHA-256 verification
|
|
136
|
+
const actualHash = await RNFS.hash(zipPath, "sha256");
|
|
137
|
+
if (actualHash.toLowerCase() !== update.sha256.toLowerCase()) {
|
|
138
|
+
await RNFS.unlink(zipPath).catch(() => { });
|
|
139
|
+
throw new Error(`SHA-256 Checksum mismatch! Expected ${update.sha256}, got ${actualHash}`);
|
|
140
|
+
}
|
|
141
|
+
// Unpack temp directory
|
|
142
|
+
const tempDir = `${TEMP_DIR}/${version}`;
|
|
143
|
+
await RNFS.unlink(tempDir).catch(() => { });
|
|
144
|
+
await RNFS.mkdir(tempDir);
|
|
145
|
+
await OTAUpdater.unzip(zipPath, tempDir);
|
|
146
|
+
const bundleName = Platform.OS === "ios" ? "main.jsbundle" : "index.android.bundle";
|
|
147
|
+
const bundleFile = `${tempDir}/${bundleName}`;
|
|
148
|
+
if (!(await RNFS.exists(bundleFile))) {
|
|
149
|
+
throw new Error(`Bundle missing after unzip: ${bundleName}`);
|
|
150
|
+
}
|
|
151
|
+
// Atomic move & backup
|
|
152
|
+
await RNFS.unlink(BACKUP_DIR).catch(() => { });
|
|
153
|
+
if (await RNFS.exists(CURRENT_DIR)) {
|
|
154
|
+
await RNFS.moveFile(CURRENT_DIR, BACKUP_DIR);
|
|
155
|
+
}
|
|
156
|
+
await RNFS.moveFile(tempDir, CURRENT_DIR);
|
|
157
|
+
// Save state
|
|
158
|
+
const oldState = await readState();
|
|
159
|
+
await writeState({
|
|
160
|
+
currentVersion: version,
|
|
161
|
+
previousVersion: oldState.currentVersion !== "0.0.0" ? oldState.currentVersion : null,
|
|
162
|
+
pending: true,
|
|
163
|
+
ready: false,
|
|
164
|
+
trial: false,
|
|
165
|
+
failedVersions: oldState.failedVersions || []
|
|
166
|
+
});
|
|
167
|
+
await RNFS.unlink(zipPath).catch(() => { });
|
|
168
|
+
if (installMode === InstallMode.IMMEDIATE) {
|
|
169
|
+
await this.restartApp();
|
|
170
|
+
}
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Phương thức đồng bộ tự động như CodePush.sync()
|
|
175
|
+
*/
|
|
176
|
+
static async sync(options, syncStatusCallback, downloadProgressCallback) {
|
|
177
|
+
try {
|
|
178
|
+
// 1. Tự động markReady khi khởi động
|
|
179
|
+
await this.markReady();
|
|
180
|
+
// 2. Kiểm tra update
|
|
181
|
+
const update = await this.checkForUpdate({
|
|
182
|
+
serverUrl: options.serverUrl,
|
|
183
|
+
appId: options.appId,
|
|
184
|
+
apiKey: options.apiKey,
|
|
185
|
+
nativeVersion: options.nativeVersion,
|
|
186
|
+
channel: options.channel
|
|
187
|
+
});
|
|
188
|
+
if (!update || !update.updateAvailable) {
|
|
189
|
+
syncStatusCallback?.(SyncStatus.UP_TO_DATE);
|
|
190
|
+
return SyncStatus.UP_TO_DATE;
|
|
191
|
+
}
|
|
192
|
+
syncStatusCallback?.(SyncStatus.UPDATE_AVAILABLE);
|
|
193
|
+
const proceedWithInstall = async () => {
|
|
194
|
+
syncStatusCallback?.(SyncStatus.DOWNLOADING_PACKAGE);
|
|
195
|
+
const serverUrlClean = options.serverUrl.replace(/\/$/, "");
|
|
196
|
+
const fullDownloadUrl = update.downloadUrl?.startsWith("http")
|
|
197
|
+
? update.downloadUrl
|
|
198
|
+
: `${serverUrlClean}${update.downloadUrl}`;
|
|
199
|
+
await this.downloadAndInstall({ ...update, downloadUrl: fullDownloadUrl }, downloadProgressCallback);
|
|
200
|
+
syncStatusCallback?.(SyncStatus.INSTALLING_UPDATE);
|
|
201
|
+
syncStatusCallback?.(SyncStatus.UPDATE_INSTALLED);
|
|
202
|
+
const effectiveInstallMode = (update.mandatory && options.mandatoryInstallMode)
|
|
203
|
+
? options.mandatoryInstallMode
|
|
204
|
+
: (options.installMode || InstallMode.ON_NEXT_RESTART);
|
|
205
|
+
if (effectiveInstallMode === InstallMode.ON_NEXT_RESTART) {
|
|
206
|
+
return SyncStatus.UPDATE_INSTALLED;
|
|
207
|
+
}
|
|
208
|
+
// Restart immediate
|
|
209
|
+
await this.restartApp();
|
|
210
|
+
return SyncStatus.UPDATE_INSTALLED;
|
|
211
|
+
};
|
|
212
|
+
// 3. Nếu cấu hình hiển thị Update Dialog
|
|
213
|
+
if (options.updateDialog) {
|
|
214
|
+
const dialogOpts = typeof options.updateDialog === "object"
|
|
215
|
+
? { ...this.defaultDialog, ...options.updateDialog }
|
|
216
|
+
: this.defaultDialog;
|
|
217
|
+
const isMandatory = Boolean(update.mandatory);
|
|
218
|
+
const message = isMandatory
|
|
219
|
+
? `${dialogOpts.mandatoryUpdateMessage}\n\nPhiên bản: ${update.otaVersion}\n${update.releaseNotes || ""}`
|
|
220
|
+
: `${dialogOpts.optionalUpdateMessage}\n\nPhiên bản: ${update.otaVersion}\n${update.releaseNotes || ""}`;
|
|
221
|
+
const buttons = [];
|
|
222
|
+
if (!isMandatory) {
|
|
223
|
+
buttons.push({
|
|
224
|
+
text: dialogOpts.optionalIgnoreButtonLabel,
|
|
225
|
+
style: "cancel"
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
buttons.push({
|
|
229
|
+
text: dialogOpts.optionalInstallButtonLabel,
|
|
230
|
+
onPress: () => {
|
|
231
|
+
proceedWithInstall();
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
Alert.alert(dialogOpts.title || "Cập nhật ứng dụng", message, buttons, {
|
|
235
|
+
cancelable: !isMandatory
|
|
236
|
+
});
|
|
237
|
+
return SyncStatus.UPDATE_AVAILABLE;
|
|
238
|
+
}
|
|
239
|
+
// Tự động tải ngầm không hiện dialog
|
|
240
|
+
return await proceedWithInstall();
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
console.error("[react-native-custom-ota] Sync error:", error);
|
|
244
|
+
syncStatusCallback?.(SyncStatus.ERROR);
|
|
245
|
+
return SyncStatus.ERROR;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
CustomOTAManager.defaultDialog = {
|
|
250
|
+
title: "Có bản cập nhật mới",
|
|
251
|
+
optionalUpdateMessage: "Đã có bản cập nhật ứng dụng mới. Bạn có muốn cài đặt ngay bây giờ?",
|
|
252
|
+
mandatoryUpdateMessage: "Đã có bản cập nhật bắt buộc. Vui lòng cài đặt để tiếp tục sử dụng.",
|
|
253
|
+
optionalInstallButtonLabel: "Cập nhật",
|
|
254
|
+
optionalIgnoreButtonLabel: "Để sau"
|
|
255
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { NativeModules } from "react-native";
|
|
2
|
+
export const OTAUpdater = NativeModules.OTAUpdater;
|
|
3
|
+
if (!OTAUpdater) {
|
|
4
|
+
console.warn("[react-native-custom-ota] NativeModule.OTAUpdater is null! " +
|
|
5
|
+
"Make sure you have linked the library and rebuilt the native binary.");
|
|
6
|
+
}
|