@hot-updater/react-native 0.20.15 → 0.21.1

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 (56) hide show
  1. package/HotUpdater.podspec +7 -4
  2. package/android/build.gradle +3 -0
  3. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +78 -21
  4. package/android/src/main/java/com/hotupdater/DecompressService.kt +83 -0
  5. package/android/src/main/java/com/hotupdater/DecompressionStrategy.kt +26 -0
  6. package/android/src/main/java/com/hotupdater/HashUtils.kt +47 -0
  7. package/android/src/main/java/com/hotupdater/HotUpdater.kt +3 -0
  8. package/android/src/main/java/com/hotupdater/HotUpdaterFactory.kt +3 -3
  9. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +3 -1
  10. package/android/src/main/java/com/hotupdater/OkHttpDownloadService.kt +293 -0
  11. package/android/src/main/java/com/hotupdater/TarBrDecompressionStrategy.kt +105 -0
  12. package/android/src/main/java/com/hotupdater/TarGzDecompressionStrategy.kt +117 -0
  13. package/android/src/main/java/com/hotupdater/ZipDecompressionStrategy.kt +175 -0
  14. package/android/src/newarch/HotUpdaterModule.kt +2 -0
  15. package/android/src/oldarch/HotUpdaterModule.kt +2 -0
  16. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +133 -60
  17. package/ios/HotUpdater/Internal/DecompressService.swift +89 -0
  18. package/ios/HotUpdater/Internal/DecompressionStrategy.swift +22 -0
  19. package/ios/HotUpdater/Internal/HashUtils.swift +63 -0
  20. package/ios/HotUpdater/Internal/HotUpdater.mm +5 -2
  21. package/ios/HotUpdater/Internal/HotUpdaterFactory.swift +4 -4
  22. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +23 -10
  23. package/ios/HotUpdater/Internal/TarBrDecompressionStrategy.swift +229 -0
  24. package/ios/HotUpdater/Internal/TarGzDecompressionStrategy.swift +177 -0
  25. package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +73 -7
  26. package/ios/HotUpdater/Internal/ZipDecompressionStrategy.swift +165 -0
  27. package/lib/commonjs/checkForUpdate.js +1 -0
  28. package/lib/commonjs/checkForUpdate.js.map +1 -1
  29. package/lib/commonjs/fetchUpdateInfo.js +1 -1
  30. package/lib/commonjs/fetchUpdateInfo.js.map +1 -1
  31. package/lib/commonjs/native.js +3 -1
  32. package/lib/commonjs/native.js.map +1 -1
  33. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  34. package/lib/module/checkForUpdate.js +1 -0
  35. package/lib/module/checkForUpdate.js.map +1 -1
  36. package/lib/module/fetchUpdateInfo.js +1 -1
  37. package/lib/module/fetchUpdateInfo.js.map +1 -1
  38. package/lib/module/native.js +3 -1
  39. package/lib/module/native.js.map +1 -1
  40. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  41. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  42. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  43. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +5 -0
  44. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  45. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  46. package/lib/typescript/module/native.d.ts.map +1 -1
  47. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +5 -0
  48. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  49. package/package.json +5 -5
  50. package/src/checkForUpdate.ts +1 -0
  51. package/src/fetchUpdateInfo.ts +1 -1
  52. package/src/native.ts +6 -0
  53. package/src/specs/NativeHotUpdater.ts +5 -0
  54. package/android/src/main/java/com/hotupdater/HttpDownloadService.kt +0 -98
  55. package/android/src/main/java/com/hotupdater/ZipFileUnzipService.kt +0 -74
  56. package/ios/HotUpdater/Internal/SSZipArchiveUnzipService.swift +0 -25
@@ -0,0 +1,293 @@
1
+ package com.hotupdater
2
+
3
+ import android.util.Log
4
+ import kotlinx.coroutines.Dispatchers
5
+ import kotlinx.coroutines.delay
6
+ import kotlinx.coroutines.withContext
7
+ import okhttp3.OkHttpClient
8
+ import okhttp3.Request
9
+ import okhttp3.Response
10
+ import okhttp3.ResponseBody
11
+ import okio.Buffer
12
+ import okio.BufferedSource
13
+ import okio.ForwardingSource
14
+ import okio.Source
15
+ import okio.buffer
16
+ import java.io.File
17
+ import java.io.IOException
18
+ import java.net.SocketTimeoutException
19
+ import java.net.URL
20
+ import java.net.UnknownHostException
21
+ import java.util.concurrent.TimeUnit
22
+
23
+ /**
24
+ * Result wrapper for download operations
25
+ */
26
+ sealed class DownloadResult {
27
+ data class Success(
28
+ val file: File,
29
+ ) : DownloadResult()
30
+
31
+ data class Error(
32
+ val exception: Exception,
33
+ ) : DownloadResult()
34
+ }
35
+
36
+ /**
37
+ * Interface for download operations
38
+ */
39
+ interface DownloadService {
40
+ /**
41
+ * Gets the file size from the URL without downloading
42
+ * @param fileUrl The URL to check
43
+ * @return File size in bytes, or -1 if unavailable
44
+ */
45
+ suspend fun getFileSize(fileUrl: URL): Long
46
+
47
+ /**
48
+ * Downloads a file from a URL
49
+ * @param fileUrl The URL to download from
50
+ * @param destination The local file to save to
51
+ * @param progressCallback Callback for download progress updates
52
+ * @return Result indicating success or failure
53
+ */
54
+ suspend fun downloadFile(
55
+ fileUrl: URL,
56
+ destination: File,
57
+ progressCallback: (Double) -> Unit,
58
+ ): DownloadResult
59
+ }
60
+
61
+ /**
62
+ * Progress tracking wrapper for OkHttp ResponseBody
63
+ */
64
+ private class ProgressResponseBody(
65
+ private val responseBody: ResponseBody,
66
+ private val progressCallback: (Double) -> Unit,
67
+ ) : ResponseBody() {
68
+ private var bufferedSource: BufferedSource? = null
69
+
70
+ override fun contentType() = responseBody.contentType()
71
+
72
+ override fun contentLength() = responseBody.contentLength()
73
+
74
+ override fun source(): BufferedSource {
75
+ if (bufferedSource == null) {
76
+ bufferedSource = source(responseBody.source()).buffer()
77
+ }
78
+ return bufferedSource!!
79
+ }
80
+
81
+ private fun source(source: Source): Source =
82
+ object : ForwardingSource(source) {
83
+ var totalBytesRead = 0L
84
+ var lastProgressTime = System.currentTimeMillis()
85
+
86
+ override fun read(
87
+ sink: Buffer,
88
+ byteCount: Long,
89
+ ): Long {
90
+ val bytesRead = super.read(sink, byteCount)
91
+ totalBytesRead += if (bytesRead != -1L) bytesRead else 0
92
+ val currentTime = System.currentTimeMillis()
93
+
94
+ if (currentTime - lastProgressTime >= 100) {
95
+ val progress = totalBytesRead.toDouble() / contentLength()
96
+ progressCallback.invoke(progress)
97
+ lastProgressTime = currentTime
98
+ }
99
+ return bytesRead
100
+ }
101
+ }
102
+ }
103
+
104
+ /**
105
+ * OkHttp-based implementation of DownloadService with resume support
106
+ */
107
+ class OkHttpDownloadService : DownloadService {
108
+ companion object {
109
+ private const val TAG = "OkHttpDownloadService"
110
+ private const val MAX_RETRIES = 3
111
+ private const val INITIAL_RETRY_DELAY_MS = 1000L
112
+ private const val TIMEOUT_SECONDS = 30L
113
+ }
114
+
115
+ private val client =
116
+ OkHttpClient
117
+ .Builder()
118
+ .connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
119
+ .readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
120
+ .writeTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
121
+ .build()
122
+
123
+ override suspend fun getFileSize(fileUrl: URL): Long =
124
+ withContext(Dispatchers.IO) {
125
+ try {
126
+ val request =
127
+ Request
128
+ .Builder()
129
+ .url(fileUrl)
130
+ .head()
131
+ .build()
132
+ client.newCall(request).execute().use { response ->
133
+ if (response.isSuccessful) {
134
+ val contentLength = response.header("Content-Length")?.toLongOrNull() ?: -1L
135
+ Log.d(TAG, "File size from HEAD request: $contentLength bytes")
136
+ contentLength
137
+ } else {
138
+ Log.d(TAG, "HEAD request failed: ${response.code}")
139
+ -1L
140
+ }
141
+ }
142
+ } catch (e: Exception) {
143
+ Log.d(TAG, "Failed to get file size: ${e.message}")
144
+ -1L
145
+ }
146
+ }
147
+
148
+ override suspend fun downloadFile(
149
+ fileUrl: URL,
150
+ destination: File,
151
+ progressCallback: (Double) -> Unit,
152
+ ): DownloadResult =
153
+ withContext(Dispatchers.IO) {
154
+ var attempt = 0
155
+ var lastException: Exception? = null
156
+
157
+ while (attempt < MAX_RETRIES) {
158
+ try {
159
+ return@withContext attemptDownload(
160
+ fileUrl,
161
+ destination,
162
+ progressCallback,
163
+ )
164
+ } catch (e: Exception) {
165
+ lastException = e
166
+ attempt++
167
+
168
+ if (attempt < MAX_RETRIES && isRetryableException(e)) {
169
+ val delayMs = INITIAL_RETRY_DELAY_MS * (1 shl (attempt - 1))
170
+ Log.d(
171
+ TAG,
172
+ "Download failed (attempt $attempt/$MAX_RETRIES): ${e.message}. Retrying in ${delayMs}ms...",
173
+ )
174
+ delay(delayMs)
175
+ } else {
176
+ Log.d(TAG, "Download failed: ${e.message}")
177
+ break
178
+ }
179
+ }
180
+ }
181
+
182
+ DownloadResult.Error(lastException ?: Exception("Download failed after $MAX_RETRIES attempts"))
183
+ }
184
+
185
+ private suspend fun attemptDownload(
186
+ fileUrl: URL,
187
+ destination: File,
188
+ progressCallback: (Double) -> Unit,
189
+ ): DownloadResult =
190
+ withContext(Dispatchers.IO) {
191
+ // Make sure parent directories exist
192
+ destination.parentFile?.mkdirs()
193
+
194
+ // Delete any existing partial file to start fresh
195
+ if (destination.exists()) {
196
+ Log.d(TAG, "Deleting existing file, starting fresh download")
197
+ destination.delete()
198
+ }
199
+
200
+ val request = Request.Builder().url(fileUrl).build()
201
+ val response: Response
202
+
203
+ try {
204
+ response = client.newCall(request).execute()
205
+ } catch (e: Exception) {
206
+ Log.d(TAG, "Failed to execute request: ${e.message}")
207
+ return@withContext DownloadResult.Error(e)
208
+ }
209
+
210
+ if (!response.isSuccessful) {
211
+ val errorMsg = "HTTP error ${response.code}: ${response.message}"
212
+ Log.d(TAG, errorMsg)
213
+ response.close()
214
+ return@withContext DownloadResult.Error(Exception(errorMsg))
215
+ }
216
+
217
+ val body = response.body
218
+ if (body == null) {
219
+ response.close()
220
+ return@withContext DownloadResult.Error(Exception("Response body is null"))
221
+ }
222
+
223
+ // Get total file size
224
+ val totalSize = body.contentLength()
225
+
226
+ if (totalSize <= 0) {
227
+ Log.d(TAG, "Invalid content length: $totalSize")
228
+ response.close()
229
+ return@withContext DownloadResult.Error(Exception("Invalid content length: $totalSize"))
230
+ }
231
+
232
+ Log.d(TAG, "Starting download: $totalSize bytes")
233
+
234
+ try {
235
+ // Wrap response body with progress tracking
236
+ val progressBody =
237
+ ProgressResponseBody(body) { progress ->
238
+ progressCallback.invoke(progress)
239
+ }
240
+
241
+ // Write to file
242
+ progressBody.source().use { source ->
243
+ destination.outputStream().use { output ->
244
+ val buffer = ByteArray(8 * 1024)
245
+ var bytesRead: Int
246
+
247
+ while (source.read(buffer).also { bytesRead = it } != -1) {
248
+ output.write(buffer, 0, bytesRead)
249
+ }
250
+ }
251
+ }
252
+
253
+ response.close()
254
+
255
+ // Verify file size
256
+ val finalSize = destination.length()
257
+ if (finalSize != totalSize) {
258
+ val errorMsg = "Download incomplete: $finalSize / $totalSize bytes"
259
+ Log.d(TAG, errorMsg)
260
+
261
+ // Delete incomplete file
262
+ destination.delete()
263
+ return@withContext DownloadResult.Error(IOException(errorMsg))
264
+ }
265
+
266
+ Log.d(TAG, "Download completed successfully: $finalSize bytes")
267
+ progressCallback.invoke(1.0)
268
+ DownloadResult.Success(destination)
269
+ } catch (e: Exception) {
270
+ response.close()
271
+ Log.d(TAG, "Failed to download data: ${e.message}")
272
+
273
+ // Delete incomplete file
274
+ if (destination.exists()) {
275
+ destination.delete()
276
+ }
277
+ DownloadResult.Error(e)
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Check if exception is retryable
283
+ */
284
+ private fun isRetryableException(e: Exception): Boolean =
285
+ when (e) {
286
+ is SocketTimeoutException,
287
+ is UnknownHostException,
288
+ is IOException,
289
+ -> true
290
+
291
+ else -> false
292
+ }
293
+ }
@@ -0,0 +1,105 @@
1
+ package com.hotupdater
2
+
3
+ import android.util.Log
4
+ import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
5
+ import org.apache.commons.compress.compressors.brotli.BrotliCompressorInputStream
6
+ import java.io.BufferedInputStream
7
+ import java.io.File
8
+ import java.io.FileInputStream
9
+ import java.io.FileOutputStream
10
+
11
+ /**
12
+ * Strategy for handling TAR+Brotli compressed files
13
+ */
14
+ class TarBrDecompressionStrategy : DecompressionStrategy {
15
+ companion object {
16
+ private const val TAG = "TarBrStrategy"
17
+ private const val MIN_FILE_SIZE = 10L
18
+ }
19
+
20
+ override fun isValid(filePath: String): Boolean {
21
+ val file = File(filePath)
22
+
23
+ if (!file.exists() || file.length() < MIN_FILE_SIZE) {
24
+ Log.d(TAG, "Invalid file: doesn't exist or too small (${file.length()} bytes)")
25
+ return false
26
+ }
27
+
28
+ // Brotli has no standard magic bytes, check file extension
29
+ val lowercasedPath = filePath.lowercase()
30
+ val isBrotli = lowercasedPath.endsWith(".tar.br") || lowercasedPath.endsWith(".br")
31
+
32
+ if (!isBrotli) {
33
+ Log.d(TAG, "Invalid file: not a .tar.br or .br file")
34
+ }
35
+
36
+ return isBrotli
37
+ }
38
+
39
+ override fun decompress(
40
+ filePath: String,
41
+ destinationPath: String,
42
+ progressCallback: (Double) -> Unit,
43
+ ): Boolean =
44
+ try {
45
+ val destinationDir = File(destinationPath)
46
+ if (!destinationDir.exists()) {
47
+ destinationDir.mkdirs()
48
+ }
49
+
50
+ val sourceFile = File(filePath)
51
+ val totalSize = sourceFile.length()
52
+ var processedBytes = 0L
53
+
54
+ Log.d(TAG, "Extracting tar.br file: $filePath")
55
+
56
+ FileInputStream(filePath).use { fileInputStream ->
57
+ BufferedInputStream(fileInputStream).use { bufferedInputStream ->
58
+ BrotliCompressorInputStream(bufferedInputStream).use { brotliInputStream ->
59
+ TarArchiveInputStream(brotliInputStream).use { tarInputStream ->
60
+ var entry = tarInputStream.nextEntry
61
+
62
+ while (entry != null) {
63
+ val file = File(destinationPath, entry.name)
64
+
65
+ if (!file.canonicalPath.startsWith(destinationDir.canonicalPath)) {
66
+ Log.w(TAG, "Skipping potentially malicious tar entry: ${entry.name}")
67
+ entry = tarInputStream.nextEntry
68
+ continue
69
+ }
70
+
71
+ if (entry.isDirectory) {
72
+ file.mkdirs()
73
+ } else {
74
+ file.parentFile?.mkdirs()
75
+
76
+ FileOutputStream(file).use { output ->
77
+ val buffer = ByteArray(8 * 1024)
78
+ var bytesRead: Int
79
+
80
+ while (tarInputStream.read(buffer).also { bytesRead = it } != -1) {
81
+ output.write(buffer, 0, bytesRead)
82
+ processedBytes += bytesRead
83
+ }
84
+ }
85
+ }
86
+
87
+ val progress = processedBytes.toDouble() / (totalSize * 2.0)
88
+ progressCallback.invoke(progress.coerceIn(0.0, 1.0))
89
+
90
+ entry = tarInputStream.nextEntry
91
+ }
92
+ }
93
+ }
94
+ }
95
+ }
96
+
97
+ Log.d(TAG, "Successfully extracted tar.br file")
98
+ progressCallback.invoke(1.0)
99
+ true
100
+ } catch (e: Exception) {
101
+ Log.d(TAG, "Failed to extract tar.br file: ${e.message}")
102
+ e.printStackTrace()
103
+ false
104
+ }
105
+ }
@@ -0,0 +1,117 @@
1
+ package com.hotupdater
2
+
3
+ import android.util.Log
4
+ import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
5
+ import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
6
+ import java.io.BufferedInputStream
7
+ import java.io.File
8
+ import java.io.FileInputStream
9
+ import java.io.FileOutputStream
10
+
11
+ /**
12
+ * Strategy for handling TAR+GZIP compressed files
13
+ */
14
+ class TarGzDecompressionStrategy : DecompressionStrategy {
15
+ companion object {
16
+ private const val TAG = "TarGzStrategy"
17
+ private const val MIN_FILE_SIZE = 10L
18
+ }
19
+
20
+ override fun isValid(filePath: String): Boolean {
21
+ val file = File(filePath)
22
+
23
+ if (!file.exists() || file.length() < MIN_FILE_SIZE) {
24
+ Log.d(TAG, "Invalid file: doesn't exist or too small (${file.length()} bytes)")
25
+ return false
26
+ }
27
+
28
+ try {
29
+ FileInputStream(file).use { fis ->
30
+ val header = ByteArray(2)
31
+ if (fis.read(header) != 2) {
32
+ Log.d(TAG, "Invalid file: cannot read header")
33
+ return false
34
+ }
35
+
36
+ // Check GZIP magic bytes (0x1F 0x8B)
37
+ val isGzip = header[0] == 0x1F.toByte() && header[1] == 0x8B.toByte()
38
+ if (!isGzip) {
39
+ Log.d(
40
+ TAG,
41
+ "Invalid file: wrong magic bytes (expected 0x1F 0x8B, got 0x${header[0].toString(16)} 0x${header[1].toString(16)})",
42
+ )
43
+ }
44
+ return isGzip
45
+ }
46
+ } catch (e: Exception) {
47
+ Log.d(TAG, "Invalid file: error reading header: ${e.message}")
48
+ return false
49
+ }
50
+ }
51
+
52
+ override fun decompress(
53
+ filePath: String,
54
+ destinationPath: String,
55
+ progressCallback: (Double) -> Unit,
56
+ ): Boolean =
57
+ try {
58
+ val destinationDir = File(destinationPath)
59
+ if (!destinationDir.exists()) {
60
+ destinationDir.mkdirs()
61
+ }
62
+
63
+ val sourceFile = File(filePath)
64
+ val totalSize = sourceFile.length()
65
+ var processedBytes = 0L
66
+
67
+ Log.d(TAG, "Extracting tar.gz file: $filePath")
68
+
69
+ FileInputStream(filePath).use { fileInputStream ->
70
+ BufferedInputStream(fileInputStream).use { bufferedInputStream ->
71
+ GzipCompressorInputStream(bufferedInputStream).use { gzipInputStream ->
72
+ TarArchiveInputStream(gzipInputStream).use { tarInputStream ->
73
+ var entry = tarInputStream.nextEntry
74
+
75
+ while (entry != null) {
76
+ val file = File(destinationPath, entry.name)
77
+
78
+ if (!file.canonicalPath.startsWith(destinationDir.canonicalPath)) {
79
+ Log.w(TAG, "Skipping potentially malicious tar entry: ${entry.name}")
80
+ entry = tarInputStream.nextEntry
81
+ continue
82
+ }
83
+
84
+ if (entry.isDirectory) {
85
+ file.mkdirs()
86
+ } else {
87
+ file.parentFile?.mkdirs()
88
+
89
+ FileOutputStream(file).use { output ->
90
+ val buffer = ByteArray(8 * 1024)
91
+ var bytesRead: Int
92
+
93
+ while (tarInputStream.read(buffer).also { bytesRead = it } != -1) {
94
+ output.write(buffer, 0, bytesRead)
95
+ processedBytes += bytesRead
96
+ }
97
+ }
98
+ }
99
+
100
+ val progress = processedBytes.toDouble() / (totalSize * 2.0)
101
+ progressCallback.invoke(progress.coerceIn(0.0, 1.0))
102
+
103
+ entry = tarInputStream.nextEntry
104
+ }
105
+ }
106
+ }
107
+ }
108
+ }
109
+
110
+ Log.d(TAG, "Successfully extracted tar.gz file")
111
+ progressCallback.invoke(1.0)
112
+ true
113
+ } catch (e: Exception) {
114
+ Log.e(TAG, "Error extracting tar.gz file: ${e.message}", e)
115
+ false
116
+ }
117
+ }
@@ -0,0 +1,175 @@
1
+ package com.hotupdater
2
+
3
+ import android.util.Log
4
+ import java.io.BufferedInputStream
5
+ import java.io.File
6
+ import java.io.FileInputStream
7
+ import java.io.FileOutputStream
8
+ import java.util.zip.CRC32
9
+ import java.util.zip.ZipEntry
10
+ import java.util.zip.ZipException
11
+ import java.util.zip.ZipFile
12
+ import java.util.zip.ZipInputStream
13
+
14
+ /**
15
+ * Strategy for handling ZIP compressed files
16
+ */
17
+ class ZipDecompressionStrategy : DecompressionStrategy {
18
+ companion object {
19
+ private const val TAG = "ZipStrategy"
20
+ private const val MIN_ZIP_SIZE = 22L
21
+ }
22
+
23
+ override fun isValid(filePath: String): Boolean {
24
+ val file = File(filePath)
25
+
26
+ if (!file.exists() || file.length() < MIN_ZIP_SIZE) {
27
+ Log.d(TAG, "Invalid ZIP: file doesn't exist or too small (${file.length()} bytes)")
28
+ return false
29
+ }
30
+
31
+ try {
32
+ FileInputStream(file).use { fis ->
33
+ val header = ByteArray(4)
34
+ if (fis.read(header) != 4) {
35
+ Log.d(TAG, "Invalid ZIP: cannot read header")
36
+ return false
37
+ }
38
+
39
+ // ZIP magic bytes: 0x50 0x4B 0x03 0x04 ("PK\u0003\u0004")
40
+ val expectedMagic = byteArrayOf(0x50.toByte(), 0x4B.toByte(), 0x03.toByte(), 0x04.toByte())
41
+
42
+ if (!header.contentEquals(expectedMagic)) {
43
+ val headerHex = header.joinToString(" ") { "0x%02X".format(it) }
44
+ Log.d(TAG, "Invalid ZIP: wrong magic bytes (expected 0x50 0x4B 0x03 0x04, got $headerHex)")
45
+ return false
46
+ }
47
+ }
48
+ } catch (e: Exception) {
49
+ Log.d(TAG, "Invalid ZIP: error reading file: ${e.message}")
50
+ return false
51
+ }
52
+
53
+ try {
54
+ ZipFile(file).use { zipFile ->
55
+ val entries = zipFile.entries()
56
+ if (!entries.hasMoreElements()) {
57
+ Log.d(TAG, "Invalid ZIP: no entries found")
58
+ return false
59
+ }
60
+
61
+ val firstEntry = entries.nextElement()
62
+ zipFile.getInputStream(firstEntry).use { stream ->
63
+ val buffer = ByteArray(1024)
64
+ stream.read(buffer)
65
+ }
66
+ }
67
+ return true
68
+ } catch (e: ZipException) {
69
+ Log.d(TAG, "Invalid ZIP: ZIP structure error: ${e.message}")
70
+ return false
71
+ } catch (e: Exception) {
72
+ Log.d(TAG, "Invalid ZIP: validation error: ${e.message}")
73
+ return false
74
+ }
75
+ }
76
+
77
+ override fun decompress(
78
+ filePath: String,
79
+ destinationPath: String,
80
+ progressCallback: (Double) -> Unit,
81
+ ): Boolean {
82
+ return try {
83
+ val destinationDir = File(destinationPath)
84
+ if (!destinationDir.exists()) {
85
+ destinationDir.mkdirs()
86
+ }
87
+
88
+ val totalEntries =
89
+ try {
90
+ ZipFile(File(filePath)).use { zipFile ->
91
+ zipFile.entries().asSequence().count()
92
+ }
93
+ } catch (e: Exception) {
94
+ Log.d(TAG, "Failed to count entries: ${e.message}")
95
+ 0
96
+ }
97
+
98
+ if (totalEntries == 0) {
99
+ Log.d(TAG, "No entries found in ZIP")
100
+ return false
101
+ }
102
+
103
+ Log.d(TAG, "Extracting $totalEntries entries from ZIP")
104
+
105
+ var extractedFileCount = 0
106
+ var processedEntries = 0
107
+
108
+ FileInputStream(filePath).use { fileInputStream ->
109
+ BufferedInputStream(fileInputStream).use { bufferedInputStream ->
110
+ ZipInputStream(bufferedInputStream).use { zipInputStream ->
111
+ var entry: ZipEntry? = zipInputStream.nextEntry
112
+ while (entry != null) {
113
+ val file = File(destinationPath, entry.name)
114
+
115
+ if (!file.canonicalPath.startsWith(destinationDir.canonicalPath)) {
116
+ Log.w(TAG, "Skipping potentially malicious zip entry: ${entry.name}")
117
+ entry = zipInputStream.nextEntry
118
+ processedEntries++
119
+ continue
120
+ }
121
+
122
+ if (entry.isDirectory) {
123
+ file.mkdirs()
124
+ } else {
125
+ file.parentFile?.mkdirs()
126
+
127
+ val crc = CRC32()
128
+ FileOutputStream(file).use { output ->
129
+ val buffer = ByteArray(8 * 1024)
130
+ var bytesRead: Int
131
+
132
+ while (zipInputStream.read(buffer).also { bytesRead = it } != -1) {
133
+ output.write(buffer, 0, bytesRead)
134
+ crc.update(buffer, 0, bytesRead)
135
+ }
136
+ }
137
+
138
+ if (entry.crc != -1L && crc.value != entry.crc) {
139
+ Log.w(TAG, "CRC mismatch for ${entry.name}: expected ${entry.crc}, got ${crc.value}")
140
+ file.delete()
141
+ return false
142
+ }
143
+
144
+ extractedFileCount++
145
+ }
146
+
147
+ zipInputStream.closeEntry()
148
+ processedEntries++
149
+
150
+ val progress = processedEntries.toDouble() / totalEntries
151
+ progressCallback.invoke(progress)
152
+
153
+ entry = zipInputStream.nextEntry
154
+ }
155
+ }
156
+ }
157
+ }
158
+
159
+ if (extractedFileCount == 0) {
160
+ Log.d(TAG, "No files extracted from ZIP")
161
+ return false
162
+ }
163
+
164
+ Log.d(TAG, "Successfully extracted $extractedFileCount files")
165
+ progressCallback.invoke(1.0)
166
+ true
167
+ } catch (e: ZipException) {
168
+ Log.d(TAG, "ZIP extraction failed: ${e.message}")
169
+ false
170
+ } catch (e: Exception) {
171
+ Log.d(TAG, "Failed to unzip file: ${e.message}")
172
+ false
173
+ }
174
+ }
175
+ }
@@ -39,11 +39,13 @@ class HotUpdaterModule internal constructor(
39
39
  try {
40
40
  val bundleId = params.getString("bundleId")!!
41
41
  val fileUrl = params.getString("fileUrl")
42
+ val fileHash = params.getString("fileHash")
42
43
  val isSuccess =
43
44
  HotUpdater.updateBundle(
44
45
  mReactApplicationContext,
45
46
  bundleId,
46
47
  fileUrl,
48
+ fileHash,
47
49
  ) { progress ->
48
50
  val progressParams =
49
51
  WritableNativeMap().apply {