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.
Files changed (48) hide show
  1. package/README.md +214 -2
  2. package/RNCustomOTA.podspec +20 -0
  3. package/android/build.gradle +21 -0
  4. package/android/src/main/java/com/customota/CustomOTABundleResolver.kt +85 -0
  5. package/android/src/main/java/com/customota/OTAUpdaterModule.kt +152 -0
  6. package/android/src/main/java/com/customota/OTAUpdaterPackage.kt +22 -0
  7. package/bin/custom-ota.js +217 -0
  8. package/ios/CustomOTABundleResolver.swift +79 -0
  9. package/ios/OTAUpdater.m +19 -0
  10. package/ios/OTAUpdater.swift +105 -0
  11. package/lib/commonjs/hoc.js +64 -0
  12. package/lib/commonjs/index.js +32 -0
  13. package/lib/commonjs/manager.js +262 -0
  14. package/lib/commonjs/native.js +9 -0
  15. package/lib/commonjs/storage.js +48 -0
  16. package/lib/commonjs/types.js +23 -0
  17. package/lib/module/hoc.js +28 -0
  18. package/lib/module/index.js +16 -0
  19. package/lib/module/manager.js +255 -0
  20. package/lib/module/native.js +6 -0
  21. package/lib/module/storage.js +39 -0
  22. package/lib/module/types.js +20 -0
  23. package/lib/typescript/hoc.d.ts +3 -0
  24. package/lib/typescript/hoc.js +64 -0
  25. package/lib/typescript/index.d.ts +16 -0
  26. package/lib/typescript/index.js +32 -0
  27. package/lib/typescript/manager.d.ts +35 -0
  28. package/lib/typescript/manager.js +262 -0
  29. package/lib/typescript/native.d.ts +8 -0
  30. package/lib/typescript/native.js +9 -0
  31. package/lib/typescript/src/hoc.d.ts +3 -0
  32. package/lib/typescript/src/index.d.ts +15 -0
  33. package/lib/typescript/src/manager.d.ts +33 -0
  34. package/lib/typescript/src/native.d.ts +8 -0
  35. package/lib/typescript/src/storage.d.ts +8 -0
  36. package/lib/typescript/src/types.d.ts +54 -0
  37. package/lib/typescript/storage.d.ts +8 -0
  38. package/lib/typescript/storage.js +48 -0
  39. package/lib/typescript/types.d.ts +60 -0
  40. package/lib/typescript/types.js +23 -0
  41. package/package.json +49 -4
  42. package/react-native.config.js +11 -0
  43. package/src/hoc.tsx +34 -0
  44. package/src/index.ts +19 -0
  45. package/src/manager.ts +328 -0
  46. package/src/native.ts +17 -0
  47. package/src/storage.ts +45 -0
  48. package/src/types.ts +66 -0
package/README.md CHANGED
@@ -1,3 +1,215 @@
1
- # Temporary Holding Version
1
+ # react-native-custom-ota ⚡
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Thư viện cập nhật ứng dụng từ xa qua mạng (Over-The-Air - OTA) tự host dành cho React Native, hoạt động tương tự như **`react-native-code-push`**, hỗ trợ đầy đủ New Architecture (Fabric/TurboModules), mã băm SHA-256 toàn vẹn và cơ chế **Tự phục hồi (Startup Rollback)** khi bản cập nhật bị crash.
4
+
5
+ ---
6
+
7
+ ## 🌟 Tính năng nổi bật
8
+
9
+ - 🔄 **Đồng bộ 1 chạm (CodePush-like API)**: Hỗ trợ `CustomOTA.sync()` và HOC `withCustomOTA(App)`.
10
+ - 🛡️ **Cơ chế Rollback an toàn**: Nếu bản cập nhật bị crash ở tầng JS trước khi boot thành công, Native Resolver sẽ tự động phát hiện và khôi phục về bản backup ổn định trước đó.
11
+ - 📦 **Tích hợp Native cực kỳ đơn giản**: Chỉ cần thêm 1 dòng code ở Android và 1 dòng ở iOS (Hỗ trợ React Native Autolinking).
12
+ - 🛠️ **Tích hợp CLI phát hành nhanh**: `npx custom-ota release` tự động chạy Metro, nén zip và upload lên server chỉ trong 1-2 giây.
13
+ - 🚀 **Tương thích cả Old Architecture & New Architecture** (React Native 0.70+ đến 0.87+).
14
+
15
+ ---
16
+
17
+ ## 📦 Cài đặt
18
+
19
+ Trong dự án React Native của bạn:
20
+
21
+ ```bash
22
+ # Cài đặt qua Yarn
23
+ yarn add react-native-custom-ota react-native-fs
24
+
25
+ # Hoặc qua NPM
26
+ npm install react-native-custom-ota react-native-fs
27
+ ```
28
+
29
+ Đối với **iOS**, cài đặt CocoaPods:
30
+ ```bash
31
+ cd ios && pod install && cd ..
32
+ ```
33
+
34
+ ---
35
+
36
+ ## ⚙️ Cấu hình Native (Chỉ 1 dòng mỗi nền tảng)
37
+
38
+ ### 1. Android
39
+ Mở file `android/app/src/main/java/.../MainApplication.kt` (hoặc `MainApplication.java`):
40
+
41
+ ```kotlin
42
+ import com.customota.CustomOTABundleResolver
43
+
44
+ class MainApplication : Application(), ReactApplication {
45
+
46
+ // New Architecture (RN 0.76+):
47
+ override val reactHost: ReactHost by lazy {
48
+ val otaBundle = CustomOTABundleResolver.getBundleFile(applicationContext)
49
+
50
+ getDefaultReactHost(
51
+ context = applicationContext,
52
+ packageList = PackageList(this).packages,
53
+ jsBundleFilePath = otaBundle, // <-- Thêm dòng này
54
+ useDevSupport = otaBundle == null && BuildConfig.DEBUG
55
+ )
56
+ }
57
+
58
+ // Hoặc với Old Architecture (ReactNativeHost):
59
+ override fun getJSBundleFile(): String? {
60
+ return CustomOTABundleResolver.getBundleFile(applicationContext) ?: super.getJSBundleFile()
61
+ }
62
+ }
63
+ ```
64
+
65
+ ---
66
+
67
+ ### 2. iOS
68
+ Mở file `ios/.../AppDelegate.swift` (hoặc `AppDelegate.mm`):
69
+
70
+ ```swift
71
+ import RNCustomOTA
72
+
73
+ class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
74
+ override func bundleURL() -> URL? {
75
+ // 1. Kiểm tra bản OTA trước tiên:
76
+ if let otaURL = CustomOTABundleResolver.currentBundleURL() {
77
+ return otaURL
78
+ }
79
+
80
+ // 2. Mặc định fallback:
81
+ #if DEBUG
82
+ return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
83
+ #else
84
+ return Bundle.main.url(forResource: "main", withExtension: "jsbundle")
85
+ #endif
86
+ }
87
+ }
88
+ ```
89
+
90
+ ---
91
+
92
+ ## 🚀 Cách sử dụng phía React Native (JS / TS)
93
+
94
+ > [!NOTE]
95
+ > **Bảo mật API Key**: Khi server bật xác thực bảo mật, bạn cần truyền `apiKey` (loại **Pull Key**, bắt đầu bằng `ota_pull_live_...`). Nếu không có key hợp lệ, server sẽ từ chối với mã lỗi `API_KEY_REQUIRED` (HTTP 401).
96
+
97
+ ### Cách 1: Sử dụng HOC (Tương tự CodePush - Khuyên dùng)
98
+
99
+ Bọc component gốc `App` bằng HOC `withCustomOTA`:
100
+
101
+ ```tsx
102
+ import React from "react";
103
+ import { View, Text } from "react-native";
104
+ import CustomOTA, { CheckFrequency } from "react-native-custom-ota";
105
+
106
+ function App() {
107
+ return (
108
+ <View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
109
+ <Text>Ứng dụng của tôi</Text>
110
+ </View>
111
+ );
112
+ }
113
+
114
+ export default CustomOTA.wrap({
115
+ serverUrl: "http://my-ota-server.com:4500",
116
+ apiKey: "ota_pull_live_xxxx", // API Key có quyền Pull để tải bản cập nhật
117
+ appId: "com.otademoapp", // Định danh ứng dụng (tuỳ chọn)
118
+ checkFrequency: CheckFrequency.ON_APP_RESUME, // Tự động check khi mở app hoặc resume
119
+ updateDialog: true, // Tự động hiện popup hỏi người dùng khi có bản mới
120
+ })(App);
121
+ ```
122
+
123
+ ---
124
+
125
+ ### Cách 2: Gọi `CustomOTA.sync()` linh hoạt
126
+
127
+ Tự gọi đồng bộ bất cứ lúc nào (ví dụ trong `useEffect` hoặc khi người dùng bấm nút):
128
+
129
+ ```tsx
130
+ import CustomOTA, { SyncStatus } from "react-native-custom-ota";
131
+
132
+ // Gọi đồng bộ tự động:
133
+ CustomOTA.sync({
134
+ serverUrl: "http://my-ota-server.com:4500",
135
+ apiKey: "ota_pull_live_xxxx", // Pull API Key
136
+ appId: "com.otademoapp",
137
+ updateDialog: {
138
+ title: "Có bản cập nhật mới!",
139
+ optionalUpdateMessage: "Bạn có muốn cài đặt ngay bây giờ?",
140
+ optionalInstallButtonLabel: "Cài đặt ngay",
141
+ optionalIgnoreButtonLabel: "Để sau"
142
+ }
143
+ }, (status) => {
144
+ switch (status) {
145
+ case SyncStatus.DOWNLOADING_PACKAGE:
146
+ console.log("Đang tải bản cập nhật...");
147
+ break;
148
+ case SyncStatus.UPDATE_INSTALLED:
149
+ console.log("Đã cài xong!");
150
+ break;
151
+ }
152
+ }, (progress) => {
153
+ console.log(`Tiến độ: ${Math.round(progress * 100)}%`);
154
+ });
155
+ ```
156
+
157
+ ---
158
+
159
+ ### Cách 3: Điều khiển thủ công từng bước
160
+
161
+ ```tsx
162
+ import CustomOTA from "react-native-custom-ota";
163
+
164
+ async function manualCheck() {
165
+ // 1. Kiểm tra update
166
+ const update = await CustomOTA.checkForUpdate({
167
+ serverUrl: "http://my-ota-server.com:4500",
168
+ apiKey: "ota_pull_live_xxxx", // Pull API Key
169
+ appId: "com.otademoapp"
170
+ });
171
+
172
+ if (update && update.updateAvailable) {
173
+ // 2. Tải và cài đặt
174
+ await CustomOTA.downloadAndInstall(update, (progress) => {
175
+ console.log("Progress:", progress);
176
+ });
177
+
178
+ // 3. Khởi động lại ứng dụng
179
+ await CustomOTA.restartApp();
180
+ }
181
+ }
182
+ ```
183
+
184
+ ---
185
+
186
+ ## 🛠️ Công cụ CLI: Phát hành bản cập nhật mới
187
+
188
+ Sau khi sửa code trong dự án React Native, bạn có thể dùng CLI đi kèm thư viện:
189
+
190
+ ```bash
191
+ # Phát hành cho iOS với Push Key (-k hoặc biến OTA_API_KEY)
192
+ npx custom-ota release -a com.otademoapp -p ios -v 1.0.4 -m "Sửa lỗi giao diện" -s http://localhost:4500 -k ota_push_live_xxxx
193
+
194
+ # Phát hành cho Android
195
+ npx custom-ota release -a com.otademoapp -p android -v 1.0.4 -s http://localhost:4500 -k ota_push_live_xxxx
196
+
197
+ # Phát hành cùng lúc cả iOS và Android qua biến môi trường
198
+ OTA_API_KEY=ota_push_live_xxxx npx custom-ota release -a com.otademoapp -p all -v 1.0.4 -s http://localhost:4500
199
+ ```
200
+
201
+ ### Các cờ hỗ trợ trong CLI:
202
+ | Cờ | Tên dài | Ý nghĩa | Mặc định |
203
+ | :--- | :--- | :--- | :--- |
204
+ | `-p` | `--platform` | Nền tảng: `android`, `ios`, `all` (Bắt buộc) | |
205
+ | `-v` | `--version` | Phiên bản OTA (Bắt buộc) | |
206
+ | `-a` | `--app-id` | Mã định danh ứng dụng | Tự đọc từ `app.json` / `package.json` |
207
+ | `-k` | `--api-key` | Push API Key (hoặc qua `OTA_API_KEY`) | |
208
+ | `-s` | `--server` | Địa chỉ OTA Server | `http://localhost:4500` |
209
+ | `-m` | `--notes` | Ghi chú phát hành | `"OTA update"` |
210
+ | `--min-native` | `--minNativeVersion` | Phiên bản Native tối thiểu hỗ trợ | `1.0.0` |
211
+
212
+ ---
213
+
214
+ ## 📄 License
215
+ MIT
@@ -0,0 +1,20 @@
1
+ require "json"
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, "package.json")))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = "RNCustomOTA"
7
+ s.version = package["version"]
8
+ s.summary = package["description"]
9
+ s.homepage = "https://github.com/custom-ota/react-native-custom-ota"
10
+ s.license = package["license"]
11
+ s.authors = package["author"]
12
+ s.platforms = { :ios => "13.4" }
13
+ s.source = { :git => "https://github.com/custom-ota/react-native-custom-ota.git", :tag => "#{s.version}" }
14
+
15
+ s.source_files = "ios/**/*.{h,m,mm,swift}"
16
+ s.requires_arc = true
17
+
18
+ s.dependency "React-Core"
19
+ s.dependency "SSZipArchive"
20
+ end
@@ -0,0 +1,21 @@
1
+ apply plugin: 'com.android.library'
2
+ apply plugin: 'kotlin-android'
3
+
4
+ android {
5
+ namespace "com.customota"
6
+ compileSdkVersion rootProject.hasProperty('compileSdkVersion') ? rootProject.compileSdkVersion : 34
7
+
8
+ defaultConfig {
9
+ minSdkVersion rootProject.hasProperty('minSdkVersion') ? rootProject.minSdkVersion : 23
10
+ targetSdkVersion rootProject.hasProperty('targetSdkVersion') ? rootProject.targetSdkVersion : 34
11
+ }
12
+
13
+ lintOptions {
14
+ abortOnError false
15
+ }
16
+ }
17
+
18
+ dependencies {
19
+ implementation 'com.facebook.react:react-android'
20
+ implementation "org.jetbrains.kotlin:kotlin-stdlib"
21
+ }
@@ -0,0 +1,85 @@
1
+ package com.customota
2
+
3
+ import android.content.Context
4
+ import java.io.File
5
+ import org.json.JSONArray
6
+ import org.json.JSONObject
7
+
8
+ object CustomOTABundleResolver {
9
+
10
+ /**
11
+ * Lấy đường dẫn file JS bundle của OTA.
12
+ * Tự động kiểm tra trạng thái crash/pending để phục hồi (Rollback) bản backup trước đó.
13
+ */
14
+ @JvmStatic
15
+ fun getBundleFile(context: Context): String? {
16
+ checkAndRollbackIfPending(context)
17
+
18
+ val file = File(
19
+ context.filesDir,
20
+ "ota/current/index.android.bundle"
21
+ )
22
+
23
+ return if (file.exists() && file.length() > 0) {
24
+ file.absolutePath
25
+ } else {
26
+ null
27
+ }
28
+ }
29
+
30
+ @JvmStatic
31
+ fun getStateFile(context: Context): File {
32
+ return File(
33
+ context.filesDir,
34
+ "ota/state.json"
35
+ )
36
+ }
37
+
38
+ @JvmStatic
39
+ fun checkAndRollbackIfPending(context: Context) {
40
+ val stateFile = getStateFile(context)
41
+ if (!stateFile.exists()) return
42
+
43
+ try {
44
+ val jsonStr = stateFile.readText()
45
+ val json = JSONObject(jsonStr)
46
+
47
+ val pending = json.optBoolean("pending", false)
48
+ if (!pending) return
49
+
50
+ val trial = json.optBoolean("trial", false)
51
+ val currentDir = File(context.filesDir, "ota/current")
52
+ val backupDir = File(context.filesDir, "ota/backup")
53
+
54
+ if (trial) {
55
+ // Đã thử chạy lần trước mà bị crash trước khi gọi markReady() -> ROLLBACK!
56
+ if (backupDir.exists()) {
57
+ currentDir.deleteRecursively()
58
+ backupDir.renameTo(currentDir)
59
+ } else {
60
+ currentDir.deleteRecursively()
61
+ }
62
+
63
+ val failedVer = json.optString("currentVersion", "")
64
+ val failedArray = json.optJSONArray("failedVersions") ?: JSONArray()
65
+ if (failedVer.isNotEmpty()) {
66
+ failedArray.put(failedVer)
67
+ }
68
+ json.put("failedVersions", failedArray)
69
+
70
+ val prevVer = json.optString("previousVersion", "0.0.0")
71
+ json.put("currentVersion", prevVer)
72
+ json.put("pending", false)
73
+ json.put("trial", false)
74
+ json.put("ready", true)
75
+ } else {
76
+ // Lần đầu mở bản mới: cho phép chạy và đánh dấu trial = true
77
+ json.put("trial", true)
78
+ }
79
+
80
+ stateFile.writeText(json.toString(2))
81
+ } catch (e: Exception) {
82
+ e.printStackTrace()
83
+ }
84
+ }
85
+ }
@@ -0,0 +1,152 @@
1
+ package com.customota
2
+
3
+ import android.app.Activity
4
+ import android.os.Handler
5
+ import android.os.Looper
6
+ import com.facebook.react.bridge.Promise
7
+ import com.facebook.react.bridge.ReactApplicationContext
8
+ import com.facebook.react.bridge.ReactContextBaseJavaModule
9
+ import com.facebook.react.bridge.ReactMethod
10
+ import java.io.File
11
+ import java.io.FileInputStream
12
+ import java.io.FileOutputStream
13
+ import java.util.zip.ZipInputStream
14
+
15
+ class OTAUpdaterModule(
16
+ private val reactContext: ReactApplicationContext
17
+ ) : ReactContextBaseJavaModule(reactContext) {
18
+
19
+ override fun getName(): String = "OTAUpdater"
20
+
21
+ @ReactMethod
22
+ fun unzip(zipPath: String, targetDir: String, promise: Promise) {
23
+ try {
24
+ val dest = File(targetDir)
25
+ if (!dest.exists()) {
26
+ dest.mkdirs()
27
+ }
28
+
29
+ val buffer = ByteArray(4096)
30
+ val zis = ZipInputStream(FileInputStream(zipPath))
31
+ var zipEntry = zis.nextEntry
32
+
33
+ while (zipEntry != null) {
34
+ val newFile = File(dest, zipEntry.name)
35
+ if (!newFile.canonicalPath.startsWith(dest.canonicalPath)) {
36
+ throw SecurityException("Zip Slip detected: " + zipEntry.name)
37
+ }
38
+
39
+ if (zipEntry.isDirectory) {
40
+ newFile.mkdirs()
41
+ } else {
42
+ newFile.parentFile?.mkdirs()
43
+ val fos = FileOutputStream(newFile)
44
+ var len: Int
45
+ while (zis.read(buffer).also { len = it } > 0) {
46
+ fos.write(buffer, 0, len)
47
+ }
48
+ fos.close()
49
+ }
50
+ zipEntry = zis.nextEntry
51
+ }
52
+ zis.closeEntry()
53
+ zis.close()
54
+
55
+ promise.resolve(null)
56
+ } catch (e: Exception) {
57
+ promise.reject("OTA_UNZIP", e)
58
+ }
59
+ }
60
+
61
+ @ReactMethod
62
+ fun markReady(promise: Promise) {
63
+ try {
64
+ val stateFile = File(
65
+ reactContext.filesDir,
66
+ "ota/state.json"
67
+ )
68
+
69
+ if (stateFile.exists()) {
70
+ val json = JSONObject(stateFile.readText())
71
+ json.put("pending", false)
72
+ json.put("ready", true)
73
+ json.put("trial", false)
74
+ stateFile.writeText(json.toString(2))
75
+
76
+ File(
77
+ reactContext.filesDir,
78
+ "ota/backup"
79
+ ).deleteRecursively()
80
+ }
81
+
82
+ promise.resolve(null)
83
+ } catch (e: Exception) {
84
+ promise.reject("OTA_READY", e)
85
+ }
86
+ }
87
+
88
+ @ReactMethod
89
+ fun getCurrentVersion(promise: Promise) {
90
+ try {
91
+ val stateFile = File(
92
+ reactContext.filesDir,
93
+ "ota/state.json"
94
+ )
95
+
96
+ if (!stateFile.exists()) {
97
+ promise.resolve("0.0.0")
98
+ return
99
+ }
100
+
101
+ val json = stateFile.readText()
102
+ val regex =
103
+ Regex("\"currentVersion\"\\s*:\\s*\"([^\"]+)\"")
104
+
105
+ promise.resolve(
106
+ regex.find(json)?.groupValues?.get(1)
107
+ ?: "0.0.0"
108
+ )
109
+ } catch (e: Exception) {
110
+ promise.reject("OTA_VERSION", e)
111
+ }
112
+ }
113
+
114
+ @ReactMethod
115
+ fun restart(promise: Promise) {
116
+ try {
117
+ val activity: Activity? =
118
+ reactContext.currentActivity
119
+
120
+ if (activity == null) {
121
+ promise.reject(
122
+ "OTA_RESTART",
123
+ "No current Activity"
124
+ )
125
+ return
126
+ }
127
+
128
+ promise.resolve(null)
129
+
130
+ Handler(Looper.getMainLooper()).postDelayed({
131
+ val packageManager =
132
+ reactContext.packageManager
133
+
134
+ val intent =
135
+ packageManager.getLaunchIntentForPackage(
136
+ reactContext.packageName
137
+ )
138
+
139
+ intent?.addFlags(
140
+ android.content.Intent.FLAG_ACTIVITY_NEW_TASK or
141
+ android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP or
142
+ android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
143
+ )
144
+
145
+ reactContext.startActivity(intent)
146
+ activity.finishAffinity()
147
+ }, 300)
148
+ } catch (e: Exception) {
149
+ promise.reject("OTA_RESTART", e)
150
+ }
151
+ }
152
+ }
@@ -0,0 +1,22 @@
1
+ package com.customota
2
+
3
+ import com.facebook.react.ReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.uimanager.ViewManager
7
+
8
+ class OTAUpdaterPackage : ReactPackage {
9
+ override fun createNativeModules(
10
+ reactContext: ReactApplicationContext
11
+ ): List<NativeModule> {
12
+ return listOf(
13
+ OTAUpdaterModule(reactContext)
14
+ )
15
+ }
16
+
17
+ override fun createViewManagers(
18
+ reactContext: ReactApplicationContext
19
+ ): List<ViewManager<*, *>> {
20
+ return emptyList()
21
+ }
22
+ }