react-native-custom-ota 1.0.2 → 1.0.4

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.
@@ -0,0 +1,155 @@
1
+ import React from "react";
2
+ import { Modal, StyleSheet, Text, View, ActivityIndicator } from "react-native";
3
+ export function formatBytes(bytes, decimals = 1) {
4
+ if (!bytes || bytes <= 0)
5
+ return "0 B";
6
+ const k = 1024;
7
+ const dm = decimals < 0 ? 0 : decimals;
8
+ const sizes = ["B", "KB", "MB", "GB"];
9
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
10
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
11
+ }
12
+ export const OTAProgressModal = ({ visible, progress, title = "Cập nhật ứng dụng", statusText = "Đang tải bản cập nhật...", version }) => {
13
+ // Chuẩn hóa dữ liệu tiến độ
14
+ let percent = 0;
15
+ let receivedBytes = 0;
16
+ let totalBytes = 0;
17
+ if (typeof progress === "number") {
18
+ percent = Math.min(100, Math.max(0, Math.round(progress * 100)));
19
+ }
20
+ else if (progress && typeof progress === "object") {
21
+ percent = Math.min(100, Math.max(0, Math.round(progress.percentage || 0)));
22
+ receivedBytes = progress.receivedBytes || 0;
23
+ totalBytes = progress.totalBytes || 0;
24
+ }
25
+ const bytesInfo = totalBytes > 0
26
+ ? `${formatBytes(receivedBytes)} / ${formatBytes(totalBytes)}`
27
+ : "";
28
+ return (React.createElement(Modal, { transparent: true, animationType: "fade", visible: visible, statusBarTranslucent: true },
29
+ React.createElement(View, { style: styles.overlay },
30
+ React.createElement(View, { style: styles.card },
31
+ React.createElement(View, { style: styles.header },
32
+ React.createElement(View, { style: styles.iconContainer },
33
+ React.createElement(Text, { style: styles.iconText }, "\u26A1")),
34
+ React.createElement(View, { style: styles.headerTextContainer },
35
+ React.createElement(Text, { style: styles.title }, title),
36
+ version ? (React.createElement(Text, { style: styles.versionBadge },
37
+ "Phi\u00EAn b\u1EA3n v",
38
+ version)) : null)),
39
+ React.createElement(Text, { style: styles.statusText }, statusText),
40
+ React.createElement(View, { style: styles.progressBarTrack },
41
+ React.createElement(View, { style: [styles.progressBarFill, { width: `${percent}%` }] })),
42
+ React.createElement(View, { style: styles.detailsRow },
43
+ React.createElement(Text, { style: styles.percentageText },
44
+ percent,
45
+ "%"),
46
+ bytesInfo ? (React.createElement(Text, { style: styles.bytesText }, bytesInfo)) : null),
47
+ React.createElement(View, { style: styles.footer },
48
+ React.createElement(ActivityIndicator, { size: "small", color: "#38bdf8", style: { marginRight: 8 } }),
49
+ React.createElement(Text, { style: styles.footerText }, "Vui l\u00F2ng kh\u00F4ng \u0111\u00F3ng \u1EE9ng d\u1EE5ng..."))))));
50
+ };
51
+ const styles = StyleSheet.create({
52
+ overlay: {
53
+ flex: 1,
54
+ backgroundColor: "rgba(0, 0, 0, 0.72)",
55
+ justifyContent: "center",
56
+ alignItems: "center",
57
+ paddingHorizontal: 24
58
+ },
59
+ card: {
60
+ width: "100%",
61
+ maxWidth: 380,
62
+ backgroundColor: "#0f172a",
63
+ borderRadius: 20,
64
+ padding: 24,
65
+ borderWidth: 1,
66
+ borderColor: "rgba(255, 255, 255, 0.12)",
67
+ shadowColor: "#000",
68
+ shadowOffset: { width: 0, height: 12 },
69
+ shadowOpacity: 0.5,
70
+ shadowRadius: 20,
71
+ elevation: 16
72
+ },
73
+ header: {
74
+ flexDirection: "row",
75
+ alignItems: "center",
76
+ marginBottom: 16
77
+ },
78
+ iconContainer: {
79
+ width: 44,
80
+ height: 44,
81
+ borderRadius: 12,
82
+ backgroundColor: "rgba(56, 189, 248, 0.15)",
83
+ justifyContent: "center",
84
+ alignItems: "center",
85
+ marginRight: 14,
86
+ borderWidth: 1,
87
+ borderColor: "rgba(56, 189, 248, 0.3)"
88
+ },
89
+ iconText: {
90
+ fontSize: 22
91
+ },
92
+ headerTextContainer: {
93
+ flex: 1
94
+ },
95
+ title: {
96
+ fontSize: 18,
97
+ fontWeight: "700",
98
+ color: "#f8fafc",
99
+ letterSpacing: 0.2
100
+ },
101
+ versionBadge: {
102
+ fontSize: 12,
103
+ color: "#38bdf8",
104
+ fontWeight: "600",
105
+ marginTop: 2
106
+ },
107
+ statusText: {
108
+ fontSize: 14,
109
+ color: "#94a3b8",
110
+ marginBottom: 16,
111
+ lineHeight: 20
112
+ },
113
+ progressBarTrack: {
114
+ height: 10,
115
+ backgroundColor: "rgba(255, 255, 255, 0.08)",
116
+ borderRadius: 999,
117
+ overflow: "hidden",
118
+ marginBottom: 10
119
+ },
120
+ progressBarFill: {
121
+ height: "100%",
122
+ backgroundColor: "#38bdf8",
123
+ borderRadius: 999
124
+ },
125
+ detailsRow: {
126
+ flexDirection: "row",
127
+ justifyContent: "space-between",
128
+ alignItems: "center",
129
+ marginBottom: 20
130
+ },
131
+ percentageText: {
132
+ fontSize: 16,
133
+ fontWeight: "800",
134
+ color: "#38bdf8"
135
+ },
136
+ bytesText: {
137
+ fontSize: 13,
138
+ color: "#64748b",
139
+ fontWeight: "500"
140
+ },
141
+ footer: {
142
+ flexDirection: "row",
143
+ alignItems: "center",
144
+ justifyContent: "center",
145
+ paddingTop: 12,
146
+ borderTopWidth: 1,
147
+ borderTopColor: "rgba(255, 255, 255, 0.06)"
148
+ },
149
+ footerText: {
150
+ fontSize: 12,
151
+ color: "#64748b",
152
+ fontWeight: "500"
153
+ }
154
+ });
155
+ export default OTAProgressModal;
package/lib/module/hoc.js CHANGED
@@ -1,11 +1,40 @@
1
- import React, { useEffect } from "react";
2
- import { AppState } from "react-native";
1
+ import React, { useEffect, useState } from "react";
2
+ import { AppState, View } from "react-native";
3
3
  import { CustomOTAManager } from "./manager";
4
- import { CheckFrequency } from "./types";
4
+ import { CheckFrequency, DetailedStatus } from "./types";
5
+ import { OTAProgressModal } from "./ProgressModal";
5
6
  export function withCustomOTA(options) {
6
7
  return function (WrappedComponent) {
7
8
  return function CustomOTAWrapper(props) {
9
+ const [isDownloading, setIsDownloading] = useState(false);
10
+ const [progress, setProgress] = useState({
11
+ totalBytes: 0,
12
+ receivedBytes: 0,
13
+ percentage: 0,
14
+ progress: 0
15
+ });
16
+ const [statusText, setStatusText] = useState("Đang tải bản cập nhật...");
8
17
  useEffect(() => {
18
+ let removeProg;
19
+ let removeStatus;
20
+ if (options.showProgressModal) {
21
+ removeProg = CustomOTAManager.addProgressListener((p) => {
22
+ setProgress(p);
23
+ setIsDownloading(true);
24
+ });
25
+ removeStatus = CustomOTAManager.addDetailedStatusListener((s, msg) => {
26
+ if (msg)
27
+ setStatusText(msg);
28
+ if (s === DetailedStatus.DOWNLOADING ||
29
+ s === DetailedStatus.VERIFYING ||
30
+ s === DetailedStatus.INSTALLING) {
31
+ setIsDownloading(true);
32
+ }
33
+ else if (s === DetailedStatus.COMPLETED || s === DetailedStatus.ERROR) {
34
+ setIsDownloading(false);
35
+ }
36
+ });
37
+ }
9
38
  // Tự động kiểm tra cập nhật khi app khởi động
10
39
  if (options.checkFrequency !== CheckFrequency.MANUAL) {
11
40
  CustomOTAManager.sync(options);
@@ -19,10 +48,19 @@ export function withCustomOTA(options) {
19
48
  });
20
49
  return () => {
21
50
  subscription.remove();
51
+ removeProg?.();
52
+ removeStatus?.();
22
53
  };
23
54
  }
55
+ return () => {
56
+ removeProg?.();
57
+ removeStatus?.();
58
+ };
24
59
  }, []);
25
- return React.createElement(WrappedComponent, { ...props });
60
+ return (React.createElement(View, { style: { flex: 1 } },
61
+ React.createElement(WrappedComponent, { ...props }),
62
+ options.showProgressModal && (React.createElement(OTAProgressModal, { visible: isDownloading, progress: progress, statusText: statusText }))));
26
63
  };
27
64
  };
28
65
  }
66
+ export default withCustomOTA;
@@ -1,9 +1,13 @@
1
1
  import { CustomOTAManager } from "./manager";
2
2
  import { withCustomOTA } from "./hoc";
3
+ import { OTAProgressModal } from "./ProgressModal";
4
+ import { useCustomOTA } from "./useCustomOTA";
3
5
  export * from "./types";
4
6
  export * from "./manager";
5
7
  export * from "./hoc";
6
8
  export * from "./storage";
9
+ export * from "./ProgressModal";
10
+ export * from "./useCustomOTA";
7
11
  const CustomOTA = {
8
12
  sync: CustomOTAManager.sync.bind(CustomOTAManager),
9
13
  checkForUpdate: CustomOTAManager.checkForUpdate.bind(CustomOTAManager),
@@ -11,6 +15,10 @@ const CustomOTA = {
11
15
  restartApp: CustomOTAManager.restartApp.bind(CustomOTAManager),
12
16
  getUpdateMetadata: CustomOTAManager.getUpdateMetadata.bind(CustomOTAManager),
13
17
  markReady: CustomOTAManager.markReady.bind(CustomOTAManager),
14
- wrap: withCustomOTA
18
+ addProgressListener: CustomOTAManager.addProgressListener.bind(CustomOTAManager),
19
+ addDetailedStatusListener: CustomOTAManager.addDetailedStatusListener.bind(CustomOTAManager),
20
+ wrap: withCustomOTA,
21
+ ProgressModal: OTAProgressModal,
22
+ useCustomOTA: useCustomOTA
15
23
  };
16
24
  export default CustomOTA;
@@ -2,8 +2,48 @@ import { Alert, Platform } from "react-native";
2
2
  import RNFS from "react-native-fs";
3
3
  import { OTAUpdater } from "./native";
4
4
  import { BACKUP_DIR, CURRENT_DIR, TEMP_DIR, ensureOTADirs, readState, writeState } from "./storage";
5
- import { InstallMode, SyncStatus } from "./types";
5
+ import { InstallMode, SyncStatus, DetailedStatus } from "./types";
6
6
  export class CustomOTAManager {
7
+ /**
8
+ * Đăng ký lắng nghe tiến độ tải về toàn cục
9
+ * @returns Hàm hủy đăng ký (unsubscribe)
10
+ */
11
+ static addProgressListener(listener) {
12
+ this.progressListeners.add(listener);
13
+ return () => {
14
+ this.progressListeners.delete(listener);
15
+ };
16
+ }
17
+ /**
18
+ * Đăng ký lắng nghe trạng thái chi tiết của quá trình OTA
19
+ * @returns Hàm hủy đăng ký (unsubscribe)
20
+ */
21
+ static addDetailedStatusListener(listener) {
22
+ this.detailedStatusListeners.add(listener);
23
+ return () => {
24
+ this.detailedStatusListeners.delete(listener);
25
+ };
26
+ }
27
+ static emitProgress(progress) {
28
+ for (const listener of this.progressListeners) {
29
+ try {
30
+ listener(progress);
31
+ }
32
+ catch (e) {
33
+ console.warn("[CustomOTA] Error in progress listener:", e);
34
+ }
35
+ }
36
+ }
37
+ static emitDetailedStatus(status, message) {
38
+ for (const listener of this.detailedStatusListeners) {
39
+ try {
40
+ listener(status, message);
41
+ }
42
+ catch (e) {
43
+ console.warn("[CustomOTA] Error in detailed status listener:", e);
44
+ }
45
+ }
46
+ }
7
47
  /**
8
48
  * Đánh dấu bản build OTA hiện tại đã khởi động thành công.
9
49
  * Xóa bản backup trước đó và tắt cờ pending.
@@ -43,7 +83,7 @@ export class CustomOTAManager {
43
83
  return readState();
44
84
  }
45
85
  /**
46
- * Kiểm tra xem server có bản cập nhật mới hay không.
86
+ * Kiểm tra xem có bản cập nhật mới từ OTA Server hay không.
47
87
  */
48
88
  static async checkForUpdate(options) {
49
89
  await ensureOTADirs();
@@ -103,7 +143,7 @@ export class CustomOTAManager {
103
143
  return data;
104
144
  }
105
145
  /**
106
- * Tải và cài đặt bản cập nhật.
146
+ * Tải và cài đặt bản cập nhật với theo dõi tiến độ chi tiết.
107
147
  */
108
148
  static async downloadAndInstall(update, onProgress, installMode) {
109
149
  const version = update.otaVersion || update.version;
@@ -115,30 +155,51 @@ export class CustomOTAManager {
115
155
  ].filter(Boolean).join(", ");
116
156
  throw new Error(`Invalid update package metadata: missing ${missingFields}`);
117
157
  }
158
+ this.emitDetailedStatus(DetailedStatus.DOWNLOADING, `Bắt đầu tải bản cập nhật v${version}...`);
118
159
  const zipPath = `${TEMP_DIR}/update-${version}.zip`;
119
160
  await RNFS.unlink(zipPath).catch(() => { });
120
- // Download zip file
161
+ // Download zip file với theo dõi byte chính xác
121
162
  const downloadRes = RNFS.downloadFile({
122
163
  fromUrl: update.downloadUrl.startsWith("http") ? update.downloadUrl : `${update.downloadUrl}`,
123
164
  toFile: zipPath,
124
165
  progressDivider: 1,
125
166
  progress: ({ bytesWritten, contentLength }) => {
126
- if (contentLength > 0 && onProgress) {
127
- onProgress(bytesWritten / contentLength);
167
+ const total = contentLength > 0 ? contentLength : (update.size || 0);
168
+ const percent = total > 0 ? Math.min(100, Math.round((bytesWritten / total) * 100)) : 0;
169
+ const progressVal = total > 0 ? Math.min(1, bytesWritten / total) : 0;
170
+ const progressObj = {
171
+ totalBytes: total,
172
+ receivedBytes: bytesWritten,
173
+ percentage: percent,
174
+ progress: progressVal
175
+ };
176
+ if (onProgress) {
177
+ // Callback nhận cả object lẫn hỗ trợ kiểu cũ nhận số
178
+ try {
179
+ onProgress(progressObj, progressVal);
180
+ }
181
+ catch {
182
+ onProgress(progressVal);
183
+ }
128
184
  }
185
+ this.emitProgress(progressObj);
129
186
  }
130
187
  });
131
188
  const res = await downloadRes.promise;
132
189
  if (res.statusCode < 200 || res.statusCode >= 300) {
190
+ this.emitDetailedStatus(DetailedStatus.ERROR, `Tải thất bại với mã lỗi HTTP ${res.statusCode}`);
133
191
  throw new Error(`Download failed with status ${res.statusCode}`);
134
192
  }
135
193
  // SHA-256 verification
194
+ this.emitDetailedStatus(DetailedStatus.VERIFYING, "Đang kiểm tra tính toàn vẹn (SHA-256)...");
136
195
  const actualHash = await RNFS.hash(zipPath, "sha256");
137
196
  if (actualHash.toLowerCase() !== update.sha256.toLowerCase()) {
138
197
  await RNFS.unlink(zipPath).catch(() => { });
198
+ this.emitDetailedStatus(DetailedStatus.ERROR, "Mã băm SHA-256 không trùng khớp!");
139
199
  throw new Error(`SHA-256 Checksum mismatch! Expected ${update.sha256}, got ${actualHash}`);
140
200
  }
141
201
  // Unpack temp directory
202
+ this.emitDetailedStatus(DetailedStatus.INSTALLING, "Đang giải nén và nạp gói cập nhật...");
142
203
  const tempDir = `${TEMP_DIR}/${version}`;
143
204
  await RNFS.unlink(tempDir).catch(() => { });
144
205
  await RNFS.mkdir(tempDir);
@@ -165,6 +226,7 @@ export class CustomOTAManager {
165
226
  failedVersions: oldState.failedVersions || []
166
227
  });
167
228
  await RNFS.unlink(zipPath).catch(() => { });
229
+ this.emitDetailedStatus(DetailedStatus.COMPLETED, `Đã cài đặt bản cập nhật v${version} thành công!`);
168
230
  if (installMode === InstallMode.IMMEDIATE) {
169
231
  await this.restartApp();
170
232
  }
@@ -251,5 +313,8 @@ CustomOTAManager.defaultDialog = {
251
313
  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
314
  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
315
  optionalInstallButtonLabel: "Cập nhật",
254
- optionalIgnoreButtonLabel: "Để sau"
316
+ optionalIgnoreButtonLabel: "Để sau",
317
+ showProgressModal: true
255
318
  };
319
+ CustomOTAManager.progressListeners = new Set();
320
+ CustomOTAManager.detailedStatusListeners = new Set();
@@ -7,6 +7,17 @@ export var SyncStatus;
7
7
  SyncStatus[SyncStatus["UPDATE_INSTALLED"] = 4] = "UPDATE_INSTALLED";
8
8
  SyncStatus[SyncStatus["ERROR"] = -1] = "ERROR";
9
9
  })(SyncStatus || (SyncStatus = {}));
10
+ export var DetailedStatus;
11
+ (function (DetailedStatus) {
12
+ DetailedStatus["IDLE"] = "IDLE";
13
+ DetailedStatus["CHECKING"] = "CHECKING";
14
+ DetailedStatus["UPDATE_AVAILABLE"] = "UPDATE_AVAILABLE";
15
+ DetailedStatus["DOWNLOADING"] = "DOWNLOADING";
16
+ DetailedStatus["VERIFYING"] = "VERIFYING";
17
+ DetailedStatus["INSTALLING"] = "INSTALLING";
18
+ DetailedStatus["COMPLETED"] = "COMPLETED";
19
+ DetailedStatus["ERROR"] = "ERROR";
20
+ })(DetailedStatus || (DetailedStatus = {}));
10
21
  export var CheckFrequency;
11
22
  (function (CheckFrequency) {
12
23
  CheckFrequency["ON_APP_START"] = "ON_APP_START";
@@ -0,0 +1,116 @@
1
+ import { useEffect, useState, useCallback } from "react";
2
+ import { CustomOTAManager } from "./manager";
3
+ import { SyncStatus, DetailedStatus } from "./types";
4
+ export function useCustomOTA() {
5
+ const [isChecking, setIsChecking] = useState(false);
6
+ const [isDownloading, setIsDownloading] = useState(false);
7
+ const [progress, setProgress] = useState({
8
+ totalBytes: 0,
9
+ receivedBytes: 0,
10
+ percentage: 0,
11
+ progress: 0
12
+ });
13
+ const [syncStatus, setSyncStatus] = useState(SyncStatus.UP_TO_DATE);
14
+ const [detailedStatus, setDetailedStatus] = useState(DetailedStatus.IDLE);
15
+ const [statusText, setStatusText] = useState("");
16
+ const [activeMetadata, setActiveMetadata] = useState(null);
17
+ const refreshMetadata = useCallback(async () => {
18
+ try {
19
+ const meta = await CustomOTAManager.getUpdateMetadata();
20
+ setActiveMetadata(meta);
21
+ return meta;
22
+ }
23
+ catch {
24
+ return {
25
+ currentVersion: "0.0.0",
26
+ previousVersion: null,
27
+ pending: false,
28
+ ready: true,
29
+ failedVersions: []
30
+ };
31
+ }
32
+ }, []);
33
+ useEffect(() => {
34
+ refreshMetadata();
35
+ // Đăng ký nhận sự kiện tiến độ toàn cục
36
+ const removeProgressListener = CustomOTAManager.addProgressListener((prog) => {
37
+ setProgress(prog);
38
+ setIsDownloading(true);
39
+ setDetailedStatus(DetailedStatus.DOWNLOADING);
40
+ setStatusText(`Đang tải bản cập nhật: ${prog.percentage}%`);
41
+ });
42
+ const removeStatusListener = CustomOTAManager.addDetailedStatusListener((status, msg) => {
43
+ setDetailedStatus(status);
44
+ if (msg)
45
+ setStatusText(msg);
46
+ if (status === DetailedStatus.DOWNLOADING) {
47
+ setIsDownloading(true);
48
+ }
49
+ else if (status === DetailedStatus.COMPLETED || status === DetailedStatus.ERROR) {
50
+ setIsDownloading(false);
51
+ refreshMetadata();
52
+ }
53
+ });
54
+ return () => {
55
+ removeProgressListener();
56
+ removeStatusListener();
57
+ };
58
+ }, [refreshMetadata]);
59
+ const checkForUpdate = useCallback(async (options) => {
60
+ setIsChecking(true);
61
+ setDetailedStatus(DetailedStatus.CHECKING);
62
+ setStatusText("Đang kiểm tra phiên bản mới...");
63
+ try {
64
+ const update = await CustomOTAManager.checkForUpdate(options);
65
+ if (update && update.updateAvailable) {
66
+ setDetailedStatus(DetailedStatus.UPDATE_AVAILABLE);
67
+ setStatusText(`Tìm thấy bản cập nhật mới v${update.otaVersion}`);
68
+ }
69
+ else {
70
+ setDetailedStatus(DetailedStatus.IDLE);
71
+ setStatusText("Ứng dụng đang ở phiên bản mới nhất");
72
+ }
73
+ return update;
74
+ }
75
+ catch (err) {
76
+ setDetailedStatus(DetailedStatus.ERROR);
77
+ setStatusText(err.message || "Lỗi khi kiểm tra cập nhật");
78
+ throw err;
79
+ }
80
+ finally {
81
+ setIsChecking(false);
82
+ }
83
+ }, []);
84
+ const sync = useCallback(async (options) => {
85
+ setIsChecking(true);
86
+ setProgress({ totalBytes: 0, receivedBytes: 0, percentage: 0, progress: 0 });
87
+ try {
88
+ const status = await CustomOTAManager.sync(options, (s) => setSyncStatus(s), (prog) => setProgress(prog));
89
+ setSyncStatus(status);
90
+ await refreshMetadata();
91
+ return status;
92
+ }
93
+ finally {
94
+ setIsChecking(false);
95
+ setIsDownloading(false);
96
+ }
97
+ }, [refreshMetadata]);
98
+ const restartApp = useCallback(async () => {
99
+ await CustomOTAManager.restartApp();
100
+ }, []);
101
+ return {
102
+ isChecking,
103
+ isDownloading,
104
+ progress,
105
+ syncStatus,
106
+ detailedStatus,
107
+ statusText,
108
+ activeMetadata,
109
+ activeVersion: activeMetadata?.currentVersion || "0.0.0",
110
+ checkForUpdate,
111
+ sync,
112
+ restartApp,
113
+ refreshMetadata
114
+ };
115
+ }
116
+ export default useCustomOTA;
@@ -0,0 +1,17 @@
1
+ import React from "react";
2
+ import { DownloadProgress } from "./types";
3
+ export interface OTAProgressModalProps {
4
+ /** Trạng thái hiển thị modal */
5
+ visible: boolean;
6
+ /** Thông tin tiến độ tải (object hoặc số thập phân 0.0 - 1.0) */
7
+ progress?: DownloadProgress | number;
8
+ /** Tiêu đề modal (Mặc định: 'Cập nhật ứng dụng') */
9
+ title?: string;
10
+ /** Thông điệp trạng thái hiện tại (vd: 'Đang tải gói cập nhật...') */
11
+ statusText?: string;
12
+ /** Phiên bản đang được tải về (vd: '1.0.9') */
13
+ version?: string;
14
+ }
15
+ export declare function formatBytes(bytes: number, decimals?: number): string;
16
+ export declare const OTAProgressModal: React.FC<OTAProgressModalProps>;
17
+ export default OTAProgressModal;