@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.
- package/HotUpdater.podspec +7 -4
- package/android/build.gradle +3 -0
- package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +78 -21
- package/android/src/main/java/com/hotupdater/DecompressService.kt +83 -0
- package/android/src/main/java/com/hotupdater/DecompressionStrategy.kt +26 -0
- package/android/src/main/java/com/hotupdater/HashUtils.kt +47 -0
- package/android/src/main/java/com/hotupdater/HotUpdater.kt +3 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterFactory.kt +3 -3
- package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +3 -1
- package/android/src/main/java/com/hotupdater/OkHttpDownloadService.kt +293 -0
- package/android/src/main/java/com/hotupdater/TarBrDecompressionStrategy.kt +105 -0
- package/android/src/main/java/com/hotupdater/TarGzDecompressionStrategy.kt +117 -0
- package/android/src/main/java/com/hotupdater/ZipDecompressionStrategy.kt +175 -0
- package/android/src/newarch/HotUpdaterModule.kt +2 -0
- package/android/src/oldarch/HotUpdaterModule.kt +2 -0
- package/ios/HotUpdater/Internal/BundleFileStorageService.swift +133 -60
- package/ios/HotUpdater/Internal/DecompressService.swift +89 -0
- package/ios/HotUpdater/Internal/DecompressionStrategy.swift +22 -0
- package/ios/HotUpdater/Internal/HashUtils.swift +63 -0
- package/ios/HotUpdater/Internal/HotUpdater.mm +5 -2
- package/ios/HotUpdater/Internal/HotUpdaterFactory.swift +4 -4
- package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +23 -10
- package/ios/HotUpdater/Internal/TarBrDecompressionStrategy.swift +229 -0
- package/ios/HotUpdater/Internal/TarGzDecompressionStrategy.swift +177 -0
- package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +73 -7
- package/ios/HotUpdater/Internal/ZipDecompressionStrategy.swift +165 -0
- package/lib/commonjs/checkForUpdate.js +1 -0
- package/lib/commonjs/checkForUpdate.js.map +1 -1
- package/lib/commonjs/fetchUpdateInfo.js +1 -1
- package/lib/commonjs/fetchUpdateInfo.js.map +1 -1
- package/lib/commonjs/native.js +3 -1
- package/lib/commonjs/native.js.map +1 -1
- package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
- package/lib/module/checkForUpdate.js +1 -0
- package/lib/module/checkForUpdate.js.map +1 -1
- package/lib/module/fetchUpdateInfo.js +1 -1
- package/lib/module/fetchUpdateInfo.js.map +1 -1
- package/lib/module/native.js +3 -1
- package/lib/module/native.js.map +1 -1
- package/lib/module/specs/NativeHotUpdater.js.map +1 -1
- package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/commonjs/native.d.ts.map +1 -1
- package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +5 -0
- package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
- package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/module/native.d.ts.map +1 -1
- package/lib/typescript/module/specs/NativeHotUpdater.d.ts +5 -0
- package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/checkForUpdate.ts +1 -0
- package/src/fetchUpdateInfo.ts +1 -1
- package/src/native.ts +6 -0
- package/src/specs/NativeHotUpdater.ts +5 -0
- package/android/src/main/java/com/hotupdater/HttpDownloadService.kt +0 -98
- package/android/src/main/java/com/hotupdater/ZipFileUnzipService.kt +0 -74
- 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 {
|