expo-tiddlywiki-filesystem-android-external-storage 2.9.0 → 2.11.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/android/src/main/java/expo/modules/externalstorage/ExternalStorageModule.kt +190 -950
- package/android/src/main/java/expo/modules/externalstorage/FileSystem.kt +477 -0
- package/android/src/main/java/expo/modules/externalstorage/GitBundle.kt +149 -0
- package/android/src/main/java/expo/modules/externalstorage/GitHelper.kt +46 -1111
- package/android/src/main/java/expo/modules/externalstorage/GitHistory.kt +192 -0
- package/android/src/main/java/expo/modules/externalstorage/GitLocal.kt +254 -0
- package/android/src/main/java/expo/modules/externalstorage/GitNetwork.kt +160 -0
- package/android/src/main/java/expo/modules/externalstorage/GitRepository.kt +154 -0
- package/android/src/main/java/expo/modules/externalstorage/GitStatus.kt +265 -0
- package/android/src/main/java/expo/modules/externalstorage/GitTransport.kt +49 -0
- package/android/src/main/java/expo/modules/externalstorage/TiddlyWikiParser.kt +262 -0
- package/build/index.d.ts +12 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +13 -0
|
@@ -1,950 +1,190 @@
|
|
|
1
|
-
package expo.modules.externalstorage
|
|
2
|
-
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
)
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
AsyncFunction("
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
AsyncFunction("deleteFile") { path: String ->
|
|
192
|
-
val file = File(path)
|
|
193
|
-
if (file.exists()) {
|
|
194
|
-
file.delete()
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// --- Helper: check if external storage is available and MANAGE permission effective ---
|
|
199
|
-
|
|
200
|
-
AsyncFunction("isExternalStorageWritable") {
|
|
201
|
-
Environment.getExternalStorageState() == Environment.MEDIA_MOUNTED
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
AsyncFunction("getExternalStorageDirectory") {
|
|
205
|
-
Environment.getExternalStorageDirectory()?.absolutePath ?: ""
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Check if this app has MANAGE_EXTERNAL_STORAGE ("All files access") granted.
|
|
210
|
-
* On Android < 11 (API 30), returns true (not needed).
|
|
211
|
-
*/
|
|
212
|
-
AsyncFunction("isExternalStorageManager") {
|
|
213
|
-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
214
|
-
Environment.isExternalStorageManager()
|
|
215
|
-
} else {
|
|
216
|
-
true
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// --- Streaming HTTP → disk ---
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Make an HTTP POST request and stream the response body directly to a file
|
|
224
|
-
* on disk, **never buffering the full body in JVM heap**.
|
|
225
|
-
*
|
|
226
|
-
* This is critical for git-upload-pack responses which can be 100+ MB.
|
|
227
|
-
* React Native's built-in `fetch()` goes through OkHttp but buffers the
|
|
228
|
-
* entire response in the JVM before handing it to Hermes, causing OOM.
|
|
229
|
-
*
|
|
230
|
-
* @param url Target URL
|
|
231
|
-
* @param headersMap HTTP headers as { key: value }
|
|
232
|
-
* @param bodyBase64 Request body encoded as Base64 (git protocol binary data)
|
|
233
|
-
* @param destPath Plain filesystem path for the response file
|
|
234
|
-
* @param contentType MIME type for the request body
|
|
235
|
-
* @return Map with "statusCode", "headers" (Map<String,String>), "bytesWritten"
|
|
236
|
-
*/
|
|
237
|
-
AsyncFunction("httpPostToFile") { url: String, headersMap: Map<String, String>, bodyBase64: String, destPath: String, contentType: String ->
|
|
238
|
-
val client = OkHttpClient.Builder()
|
|
239
|
-
.connectTimeout(30, TimeUnit.SECONDS)
|
|
240
|
-
.readTimeout(5, TimeUnit.MINUTES) // large packs take time
|
|
241
|
-
.writeTimeout(30, TimeUnit.SECONDS)
|
|
242
|
-
.build()
|
|
243
|
-
|
|
244
|
-
val requestBody = Base64.decode(bodyBase64, Base64.DEFAULT)
|
|
245
|
-
.toRequestBody(contentType.toMediaType())
|
|
246
|
-
|
|
247
|
-
val request = Request.Builder()
|
|
248
|
-
.url(url)
|
|
249
|
-
.post(requestBody)
|
|
250
|
-
.headers(headersMap.toHeaders())
|
|
251
|
-
.build()
|
|
252
|
-
|
|
253
|
-
val response = client.newCall(request).execute()
|
|
254
|
-
|
|
255
|
-
val destFile = File(destPath)
|
|
256
|
-
destFile.parentFile?.let { parent ->
|
|
257
|
-
if (!parent.exists()) parent.mkdirs()
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
var bytesWritten = 0L
|
|
261
|
-
response.body?.let { body ->
|
|
262
|
-
body.byteStream().use { inputStream ->
|
|
263
|
-
FileOutputStream(destFile).use { outputStream ->
|
|
264
|
-
val buffer = ByteArray(64 * 1024) // 64 KB chunks
|
|
265
|
-
var read: Int
|
|
266
|
-
while (inputStream.read(buffer).also { read = it } != -1) {
|
|
267
|
-
outputStream.write(buffer, 0, read)
|
|
268
|
-
bytesWritten += read
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
val responseHeaders = mutableMapOf<String, String>()
|
|
275
|
-
for (i in 0 until response.headers.size) {
|
|
276
|
-
responseHeaders[response.headers.name(i)] = response.headers.value(i)
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
mapOf(
|
|
280
|
-
"statusCode" to response.code,
|
|
281
|
-
"headers" to responseHeaders,
|
|
282
|
-
"bytesWritten" to bytesWritten,
|
|
283
|
-
)
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// --- Chunked file reading ---
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* Read a chunk of a file as Base64, starting at `offset` for up to `length`
|
|
290
|
-
* bytes. Returns `{ data: string, bytesRead: number }`.
|
|
291
|
-
*
|
|
292
|
-
* This lets JS consume a large temp file in bounded-memory chunks without
|
|
293
|
-
* ever holding the full content in the Hermes heap.
|
|
294
|
-
*/
|
|
295
|
-
AsyncFunction("readFileChunk") { path: String, offset: Long, length: Int ->
|
|
296
|
-
val file = RandomAccessFile(path, "r")
|
|
297
|
-
file.use { raf ->
|
|
298
|
-
val fileLength = raf.length()
|
|
299
|
-
if (offset >= fileLength) {
|
|
300
|
-
return@AsyncFunction mapOf(
|
|
301
|
-
"data" to "",
|
|
302
|
-
"bytesRead" to 0,
|
|
303
|
-
)
|
|
304
|
-
}
|
|
305
|
-
raf.seek(offset)
|
|
306
|
-
val toRead = minOf(length.toLong(), fileLength - offset).toInt()
|
|
307
|
-
val buffer = ByteArray(toRead)
|
|
308
|
-
val bytesRead = raf.read(buffer, 0, toRead)
|
|
309
|
-
if (bytesRead <= 0) {
|
|
310
|
-
return@AsyncFunction mapOf(
|
|
311
|
-
"data" to "",
|
|
312
|
-
"bytesRead" to 0,
|
|
313
|
-
)
|
|
314
|
-
}
|
|
315
|
-
val actual = if (bytesRead < toRead) buffer.copyOf(bytesRead) else buffer
|
|
316
|
-
mapOf(
|
|
317
|
-
"data" to Base64.encodeToString(actual, Base64.NO_WRAP),
|
|
318
|
-
"bytesRead" to bytesRead,
|
|
319
|
-
)
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// --- Resumable HTTP download → disk ---
|
|
324
|
-
|
|
325
|
-
/**
|
|
326
|
-
* Download a file via HTTP GET with support for resumable downloads.
|
|
327
|
-
*
|
|
328
|
-
* If `destPath` already exists, sends a `Range: bytes=<existingSize>-`
|
|
329
|
-
* header to resume the download from where it left off.
|
|
330
|
-
*
|
|
331
|
-
* The server must respond with 206 Partial Content and the correct
|
|
332
|
-
* Content-Range header for resume to work. If the server responds
|
|
333
|
-
* with 200, the file is overwritten from the start (full download).
|
|
334
|
-
*
|
|
335
|
-
* @param url Target URL
|
|
336
|
-
* @param headers Extra HTTP headers (e.g. Authorization, ETag)
|
|
337
|
-
* @param destPath Plain filesystem path for the downloaded file
|
|
338
|
-
* @return Map with "statusCode", "totalBytes" (final file size), "resumed" (boolean)
|
|
339
|
-
*/
|
|
340
|
-
AsyncFunction("downloadFileResumable") { url: String, headersMap: Map<String, String>, destPath: String ->
|
|
341
|
-
val destFile = File(destPath)
|
|
342
|
-
destFile.parentFile?.let { parent ->
|
|
343
|
-
if (!parent.exists()) parent.mkdirs()
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
val existingBytes = if (destFile.exists()) destFile.length() else 0L
|
|
347
|
-
|
|
348
|
-
val client = OkHttpClient.Builder()
|
|
349
|
-
.connectTimeout(30, TimeUnit.SECONDS)
|
|
350
|
-
.readTimeout(10, TimeUnit.MINUTES) // large archives
|
|
351
|
-
.writeTimeout(30, TimeUnit.SECONDS)
|
|
352
|
-
.build()
|
|
353
|
-
|
|
354
|
-
val requestBuilder = Request.Builder()
|
|
355
|
-
.url(url)
|
|
356
|
-
.headers(headersMap.toHeaders())
|
|
357
|
-
|
|
358
|
-
// Request resume if we have partial data
|
|
359
|
-
if (existingBytes > 0) {
|
|
360
|
-
requestBuilder.addHeader("Range", "bytes=$existingBytes-")
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
val request = requestBuilder.build()
|
|
364
|
-
val response = client.newCall(request).execute()
|
|
365
|
-
|
|
366
|
-
val statusCode = response.code
|
|
367
|
-
var resumed = false
|
|
368
|
-
|
|
369
|
-
response.body?.let { body ->
|
|
370
|
-
if (statusCode == 206) {
|
|
371
|
-
// Server supports Range — append to existing file
|
|
372
|
-
resumed = true
|
|
373
|
-
FileOutputStream(destFile, true).use { outputStream ->
|
|
374
|
-
body.byteStream().use { inputStream ->
|
|
375
|
-
val buffer = ByteArray(64 * 1024)
|
|
376
|
-
var read: Int
|
|
377
|
-
while (inputStream.read(buffer).also { read = it } != -1) {
|
|
378
|
-
outputStream.write(buffer, 0, read)
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
} else {
|
|
383
|
-
// Full download (200 or other) — overwrite
|
|
384
|
-
FileOutputStream(destFile, false).use { outputStream ->
|
|
385
|
-
body.byteStream().use { inputStream ->
|
|
386
|
-
val buffer = ByteArray(64 * 1024)
|
|
387
|
-
var read: Int
|
|
388
|
-
while (inputStream.read(buffer).also { read = it } != -1) {
|
|
389
|
-
outputStream.write(buffer, 0, read)
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
mapOf(
|
|
397
|
-
"statusCode" to statusCode,
|
|
398
|
-
"totalBytes" to destFile.length(),
|
|
399
|
-
"resumed" to resumed,
|
|
400
|
-
)
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
// --- Tar extraction ---
|
|
404
|
-
|
|
405
|
-
/**
|
|
406
|
-
* Extract a tar archive (uncompressed) to a destination directory.
|
|
407
|
-
*
|
|
408
|
-
* Uses a minimal tar parser: reads 512-byte headers, extracts file
|
|
409
|
-
* name and size, writes content. Supports POSIX ustar long paths
|
|
410
|
-
* via the "L" (LongLink) type extension.
|
|
411
|
-
*
|
|
412
|
-
* This avoids any third-party dependency while handling the tar
|
|
413
|
-
* files generated by `git archive` + system tar.
|
|
414
|
-
*
|
|
415
|
-
* @param tarPath Path to the .tar file
|
|
416
|
-
* @param destDir Destination directory (will be created if needed)
|
|
417
|
-
* @return Map with "filesExtracted" count
|
|
418
|
-
*/
|
|
419
|
-
AsyncFunction("extractTar") { tarPath: String, destDir: String ->
|
|
420
|
-
val tarFile = File(tarPath)
|
|
421
|
-
if (!tarFile.exists()) {
|
|
422
|
-
throw Exception("ENOENT: tar file not found: $tarPath")
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
val dest = File(destDir)
|
|
426
|
-
if (!dest.exists()) dest.mkdirs()
|
|
427
|
-
|
|
428
|
-
// Resolve the canonical dest to prevent path traversal
|
|
429
|
-
val canonicalDest = dest.canonicalPath
|
|
430
|
-
|
|
431
|
-
var filesExtracted = 0
|
|
432
|
-
var longName: String? = null
|
|
433
|
-
|
|
434
|
-
BufferedInputStream(FileInputStream(tarFile), 256 * 1024).use { bis ->
|
|
435
|
-
val headerBuf = ByteArray(512)
|
|
436
|
-
|
|
437
|
-
while (true) {
|
|
438
|
-
// Read 512-byte tar header
|
|
439
|
-
val headerRead = readFully(bis, headerBuf)
|
|
440
|
-
if (headerRead < 512) break
|
|
441
|
-
|
|
442
|
-
// Check for end-of-archive (two zero blocks)
|
|
443
|
-
if (headerBuf.all { it == 0.toByte() }) break
|
|
444
|
-
|
|
445
|
-
// Parse file name (bytes 0-99, null-terminated)
|
|
446
|
-
val rawName = extractString(headerBuf, 0, 100)
|
|
447
|
-
// Parse size (bytes 124-135, octal)
|
|
448
|
-
val sizeStr = extractString(headerBuf, 124, 12).trim()
|
|
449
|
-
val fileSize = if (sizeStr.isEmpty()) 0L else sizeStr.toLong(8)
|
|
450
|
-
// Parse type flag (byte 156)
|
|
451
|
-
val typeFlag = headerBuf[156].toInt().toChar()
|
|
452
|
-
// Parse prefix (bytes 345-499, POSIX ustar)
|
|
453
|
-
val prefix = extractString(headerBuf, 345, 155)
|
|
454
|
-
|
|
455
|
-
// Handle GNU tar long-name extension (type 'L')
|
|
456
|
-
if (typeFlag == 'L') {
|
|
457
|
-
val nameBuf = ByteArray(fileSize.toInt())
|
|
458
|
-
readFully(bis, nameBuf)
|
|
459
|
-
longName = String(nameBuf, Charsets.UTF_8).trimEnd('\u0000')
|
|
460
|
-
// Skip padding to 512-byte boundary
|
|
461
|
-
val remainder = (512 - (fileSize % 512).toInt()) % 512
|
|
462
|
-
if (remainder > 0) bis.skip(remainder.toLong())
|
|
463
|
-
continue
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
// Handle POSIX pax extended header (type 'x' or 'g')
|
|
467
|
-
// pax headers contain key=value pairs, including "path" for long filenames
|
|
468
|
-
if (typeFlag == 'x' || typeFlag == 'g') {
|
|
469
|
-
val paxBuf = ByteArray(fileSize.toInt())
|
|
470
|
-
readFully(bis, paxBuf)
|
|
471
|
-
val paxStr = String(paxBuf, Charsets.UTF_8)
|
|
472
|
-
// Parse pax records: each record is "<length> <key>=<value>\n"
|
|
473
|
-
var paxPos = 0
|
|
474
|
-
while (paxPos < paxStr.length) {
|
|
475
|
-
val spaceAt = paxStr.indexOf(' ', paxPos)
|
|
476
|
-
if (spaceAt < 0) break
|
|
477
|
-
val recLen = paxStr.substring(paxPos, spaceAt).toIntOrNull() ?: break
|
|
478
|
-
val record = paxStr.substring(spaceAt + 1, minOf(paxPos + recLen, paxStr.length)).trimEnd('\n')
|
|
479
|
-
val eqAt = record.indexOf('=')
|
|
480
|
-
if (eqAt >= 0) {
|
|
481
|
-
val key = record.substring(0, eqAt)
|
|
482
|
-
val value = record.substring(eqAt + 1)
|
|
483
|
-
if (key == "path") {
|
|
484
|
-
longName = value
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
paxPos += recLen
|
|
488
|
-
}
|
|
489
|
-
// Skip padding to 512-byte boundary
|
|
490
|
-
val remainder = (512 - (fileSize % 512).toInt()) % 512
|
|
491
|
-
if (remainder > 0) bis.skip(remainder.toLong())
|
|
492
|
-
continue
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
// Determine the file name
|
|
496
|
-
val fileName = longName ?: if (prefix.isNotEmpty()) "$prefix/$rawName" else rawName
|
|
497
|
-
longName = null
|
|
498
|
-
|
|
499
|
-
if (fileName.isEmpty()) {
|
|
500
|
-
// Skip data blocks for this entry
|
|
501
|
-
skipDataBlocks(bis, fileSize)
|
|
502
|
-
continue
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// Type '5' = directory, '0' or '\0' = regular file
|
|
506
|
-
when (typeFlag) {
|
|
507
|
-
'5' -> {
|
|
508
|
-
val dir = File(dest, fileName)
|
|
509
|
-
if (!dir.canonicalPath.startsWith(canonicalDest)) {
|
|
510
|
-
throw Exception("Path traversal detected: $fileName")
|
|
511
|
-
}
|
|
512
|
-
dir.mkdirs()
|
|
513
|
-
skipDataBlocks(bis, fileSize)
|
|
514
|
-
}
|
|
515
|
-
'0', '\u0000' -> {
|
|
516
|
-
val outFile = File(dest, fileName)
|
|
517
|
-
if (!outFile.canonicalPath.startsWith(canonicalDest)) {
|
|
518
|
-
throw Exception("Path traversal detected: $fileName")
|
|
519
|
-
}
|
|
520
|
-
outFile.parentFile?.let { parent ->
|
|
521
|
-
if (!parent.exists()) parent.mkdirs()
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
FileOutputStream(outFile).use { fos ->
|
|
525
|
-
var remaining = fileSize
|
|
526
|
-
val buf = ByteArray(64 * 1024)
|
|
527
|
-
while (remaining > 0) {
|
|
528
|
-
val toRead = minOf(remaining, buf.size.toLong()).toInt()
|
|
529
|
-
val read = bis.read(buf, 0, toRead)
|
|
530
|
-
if (read <= 0) break
|
|
531
|
-
fos.write(buf, 0, read)
|
|
532
|
-
remaining -= read
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
// Skip padding to 512-byte boundary
|
|
537
|
-
val remainder = (512 - (fileSize % 512).toInt()) % 512
|
|
538
|
-
if (remainder > 0) bis.skip(remainder.toLong())
|
|
539
|
-
|
|
540
|
-
filesExtracted++
|
|
541
|
-
}
|
|
542
|
-
else -> {
|
|
543
|
-
// Skip unknown entry types (symlinks, etc.)
|
|
544
|
-
skipDataBlocks(bis, fileSize)
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
mapOf("filesExtracted" to filesExtracted)
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
// ─── Git operations (all via JGit) ─────────────────────────────────
|
|
554
|
-
|
|
555
|
-
AsyncFunction("gitStatus") { gitRootDir: String ->
|
|
556
|
-
GitHelper.gitStatus(gitRootDir)
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
AsyncFunction("gitStatusDebug") { gitRootDir: String ->
|
|
560
|
-
GitHelper.gitStatusDebug(gitRootDir)
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
AsyncFunction("buildGitIndex") { gitRootDir: String ->
|
|
564
|
-
GitHelper.buildGitIndex(gitRootDir)
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
AsyncFunction("gitPush") { gitRootDir: String, remoteName: String, localBranch: String, remoteBranch: String, force: Boolean, headers: String? ->
|
|
568
|
-
GitHelper.gitPush(gitRootDir, remoteName, localBranch, remoteBranch, force, headers)
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
AsyncFunction("gitCreateBundle") { gitRootDir: String, remoteName: String, localBranch: String, remoteBranch: String ->
|
|
572
|
-
GitHelper.gitCreateBundle(gitRootDir, remoteName, localBranch, remoteBranch)
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
AsyncFunction("gitFetch") { gitRootDir: String, remoteName: String, branch: String, headers: String? ->
|
|
576
|
-
GitHelper.gitFetch(gitRootDir, remoteName, branch, headers)
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
AsyncFunction("gitCheckoutChangedFiles") { gitRootDir: String, oldOid: String, newOid: String ->
|
|
580
|
-
GitHelper.gitCheckoutChangedFiles(gitRootDir, oldOid, newOid)
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
AsyncFunction("gitAddAndCommit") { gitRootDir: String, message: String, authorName: String, authorEmail: String ->
|
|
584
|
-
GitHelper.gitAddAndCommit(gitRootDir, message, authorName, authorEmail)
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
AsyncFunction("gitReset") { gitRootDir: String, ref: String, mode: String ->
|
|
588
|
-
GitHelper.gitReset(gitRootDir, ref, mode)
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
AsyncFunction("gitClone") { url: String, directory: String, branch: String?, depth: Int, singleBranch: Boolean, noTags: Boolean, headers: String? ->
|
|
592
|
-
GitHelper.gitClone(url, directory, branch, depth, singleBranch, noTags, headers)
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
AsyncFunction("gitLog") { gitRootDir: String, ref: String?, maxCount: Int ->
|
|
596
|
-
GitHelper.gitLog(gitRootDir, ref, maxCount)
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
AsyncFunction("gitResolveRef") { gitRootDir: String, ref: String ->
|
|
600
|
-
GitHelper.gitResolveRef(gitRootDir, ref)
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
AsyncFunction("gitCurrentBranch") { gitRootDir: String ->
|
|
604
|
-
GitHelper.gitCurrentBranch(gitRootDir)
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
AsyncFunction("gitInit") { directory: String, defaultBranch: String ->
|
|
608
|
-
GitHelper.gitInit(directory, defaultBranch)
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
AsyncFunction("gitSetConfig") { gitRootDir: String, section: String, subsection: String?, name: String, value: String ->
|
|
612
|
-
GitHelper.gitSetConfig(gitRootDir, section, subsection, name, value)
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
AsyncFunction("gitAddRemote") { gitRootDir: String, remoteName: String, url: String ->
|
|
616
|
-
GitHelper.gitAddRemote(gitRootDir, remoteName, url)
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
AsyncFunction("gitReadBlob") { gitRootDir: String, ref: String, filepath: String, asBase64: Boolean ->
|
|
620
|
-
GitHelper.gitReadBlob(gitRootDir, ref, filepath, asBase64)
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
AsyncFunction("gitDiffTrees") { gitRootDir: String, oldRef: String, newRef: String ->
|
|
624
|
-
GitHelper.gitDiffTrees(gitRootDir, oldRef, newRef)
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
AsyncFunction("gitDiscardFileChanges") { gitRootDir: String, filepath: String ->
|
|
628
|
-
GitHelper.gitDiscardFileChanges(gitRootDir, filepath)
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
// ─── TiddlyWiki batch file parsing ─────────────────────────────────
|
|
632
|
-
|
|
633
|
-
/**
|
|
634
|
-
* Parse a batch of TiddlyWiki tiddler files entirely in Kotlin.
|
|
635
|
-
*
|
|
636
|
-
* This is the critical performance optimization: instead of making
|
|
637
|
-
* 100+ JS→Native bridge calls (one per file), a single call parses
|
|
638
|
-
* an entire batch and returns a ready-to-inject JSON array string.
|
|
639
|
-
*
|
|
640
|
-
* Supports:
|
|
641
|
-
* - .tid files: header + body, with skinny mode (omit text for large tiddlers)
|
|
642
|
-
* - .json files: single tiddler or array of tiddlers (also plugin bundles)
|
|
643
|
-
* - .meta files: metadata companion for binary/.json files
|
|
644
|
-
*
|
|
645
|
-
* @param filePaths Array of absolute file paths to parse
|
|
646
|
-
* @param quickLoadMode If true, always return skinny tiddlers (no text)
|
|
647
|
-
* @return JSON string: array of tiddler objects, e.g. `[{"title":"...","text":"..."}, ...]`
|
|
648
|
-
*/
|
|
649
|
-
AsyncFunction("batchParseTidFiles") { filePaths: List<String>, quickLoadMode: Boolean ->
|
|
650
|
-
// Parse all files in parallel using a thread pool.
|
|
651
|
-
// Expo's AsyncFunction already runs off the main thread, so we
|
|
652
|
-
// use Java's ForkJoinPool (via parallelStream) for concurrent I/O.
|
|
653
|
-
val results = filePaths.parallelStream().map { path ->
|
|
654
|
-
try {
|
|
655
|
-
parseTiddlerFile(path, quickLoadMode)
|
|
656
|
-
} catch (e: Exception) {
|
|
657
|
-
null
|
|
658
|
-
}
|
|
659
|
-
}.toList()
|
|
660
|
-
|
|
661
|
-
// Build a JSON array string directly — avoids JS-side JSON.stringify
|
|
662
|
-
val jsonArray = JSONArray()
|
|
663
|
-
for (result in results) {
|
|
664
|
-
if (result == null) continue
|
|
665
|
-
when (result) {
|
|
666
|
-
is JSONObject -> jsonArray.put(result)
|
|
667
|
-
is JSONArray -> {
|
|
668
|
-
for (i in 0 until result.length()) {
|
|
669
|
-
jsonArray.put(result.getJSONObject(i))
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
jsonArray.toString()
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
// ─── TiddlyWiki file parsing helpers ───────────────────────────────
|
|
679
|
-
|
|
680
|
-
/**
|
|
681
|
-
* Parse a single tiddler file. Returns JSONObject, JSONArray (for .json
|
|
682
|
-
* arrays), or null if the file cannot be parsed.
|
|
683
|
-
*/
|
|
684
|
-
private fun parseTiddlerFile(path: String, quickLoadMode: Boolean): Any? {
|
|
685
|
-
val file = File(path)
|
|
686
|
-
if (!file.exists()) return null
|
|
687
|
-
val name = file.name
|
|
688
|
-
|
|
689
|
-
return when {
|
|
690
|
-
name.endsWith(".tid") -> parseDotTid(file, quickLoadMode)
|
|
691
|
-
name.endsWith(".json") -> parseDotJson(file, quickLoadMode)
|
|
692
|
-
name.endsWith(".meta") -> parseDotMeta(file, quickLoadMode)
|
|
693
|
-
else -> null
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
/**
|
|
698
|
-
* Parse a .tid file (TiddlyWiki native format).
|
|
699
|
-
* Format: `key: value\n` headers, blank line, then body text.
|
|
700
|
-
*/
|
|
701
|
-
private fun parseDotTid(file: File, quickLoadMode: Boolean): JSONObject? {
|
|
702
|
-
val content = file.readText(Charsets.UTF_8)
|
|
703
|
-
val json = JSONObject()
|
|
704
|
-
|
|
705
|
-
// Find the first blank line separating headers from body
|
|
706
|
-
val blankLineRegex = Regex("\r?\n\r?\n")
|
|
707
|
-
val match = blankLineRegex.find(content)
|
|
708
|
-
val headerText = if (match != null) content.substring(0, match.range.first) else content
|
|
709
|
-
val bodyOffset = match?.let { it.range.last + 1 } ?: -1
|
|
710
|
-
val estimatedBodyLength = if (bodyOffset >= 0) content.length - bodyOffset else 0
|
|
711
|
-
|
|
712
|
-
// Parse header lines
|
|
713
|
-
for (line in headerText.split(Regex("\r?\n"))) {
|
|
714
|
-
val colonIndex = line.indexOf(':')
|
|
715
|
-
if (colonIndex != -1) {
|
|
716
|
-
val fieldName = line.substring(0, colonIndex).trim()
|
|
717
|
-
val fieldValue = line.substring(colonIndex + 1).trim()
|
|
718
|
-
if (fieldName.isNotEmpty()) {
|
|
719
|
-
json.put(fieldName, fieldValue)
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
// Use filename as title fallback
|
|
725
|
-
if (!json.has("title")) {
|
|
726
|
-
json.put("title", getTitleFromFilename(file.name))
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
val title = json.optString("title", "")
|
|
730
|
-
val type = json.optString("type", "")
|
|
731
|
-
val hasModuleType = json.has("module-type")
|
|
732
|
-
val hasPluginType = json.has("plugin-type")
|
|
733
|
-
|
|
734
|
-
// Quick load still needs full text for boot-critical tiddlers.
|
|
735
|
-
val shouldIncludeText = if (quickLoadMode) {
|
|
736
|
-
shouldPreserveFullTextInQuickLoad(title, type, hasModuleType, hasPluginType)
|
|
737
|
-
} else {
|
|
738
|
-
shouldSaveFullTiddler(title, type, hasModuleType, hasPluginType, estimatedBodyLength)
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
if (shouldIncludeText && bodyOffset >= 0 && estimatedBodyLength > 0) {
|
|
742
|
-
json.put("text", content.substring(bodyOffset))
|
|
743
|
-
} else if (!shouldIncludeText) {
|
|
744
|
-
// Skinny tiddler — mark for lazy loading
|
|
745
|
-
json.remove("text")
|
|
746
|
-
json.put("_is_skinny", "yes")
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
return json
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
/**
|
|
753
|
-
* Parse a .json tiddler file.
|
|
754
|
-
* Can be: single tiddler `{title: ...}`, array of tiddlers, or
|
|
755
|
-
* a plugin bundle `{tiddlers: {...}}` (returned as null — loaded via .meta).
|
|
756
|
-
*/
|
|
757
|
-
private fun parseDotJson(file: File, quickLoadMode: Boolean): Any? {
|
|
758
|
-
val content = file.readText(Charsets.UTF_8)
|
|
759
|
-
val fallbackTitle = getTitleFromFilename(file.name)
|
|
760
|
-
return try {
|
|
761
|
-
// Try as JSON array first
|
|
762
|
-
if (content.trimStart().startsWith("[")) {
|
|
763
|
-
val array = JSONArray(content)
|
|
764
|
-
val result = JSONArray()
|
|
765
|
-
for (i in 0 until array.length()) {
|
|
766
|
-
val obj = array.optJSONObject(i)
|
|
767
|
-
if (obj != null && obj.has("title")) {
|
|
768
|
-
result.put(obj)
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
if (result.length() > 0) {
|
|
772
|
-
result
|
|
773
|
-
} else {
|
|
774
|
-
createStandaloneJsonTiddler(fallbackTitle, content, quickLoadMode)
|
|
775
|
-
}
|
|
776
|
-
} else {
|
|
777
|
-
val obj = JSONObject(content)
|
|
778
|
-
if (obj.has("title")) {
|
|
779
|
-
if (!obj.has("type")) {
|
|
780
|
-
obj.put("type", "application/json")
|
|
781
|
-
}
|
|
782
|
-
obj
|
|
783
|
-
} else if (obj.has("tiddlers")) {
|
|
784
|
-
// Plugin bundle format {tiddlers: {...}} — skip here,
|
|
785
|
-
// it's loaded via .meta companion file
|
|
786
|
-
null
|
|
787
|
-
} else {
|
|
788
|
-
createStandaloneJsonTiddler(fallbackTitle, content, quickLoadMode)
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
} catch (_: Exception) {
|
|
792
|
-
createStandaloneJsonTiddler(fallbackTitle, content, quickLoadMode)
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
private fun createStandaloneJsonTiddler(title: String, content: String, quickLoadMode: Boolean): JSONObject {
|
|
797
|
-
val json = JSONObject()
|
|
798
|
-
json.put("title", title)
|
|
799
|
-
json.put("type", "application/json")
|
|
800
|
-
if (quickLoadMode) {
|
|
801
|
-
json.put("_is_skinny", "yes")
|
|
802
|
-
} else {
|
|
803
|
-
json.put("text", content)
|
|
804
|
-
}
|
|
805
|
-
return json
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
/**
|
|
809
|
-
* Parse a .meta companion file. The .meta has only field definitions;
|
|
810
|
-
* the actual content is in the companion file (same name without .meta).
|
|
811
|
-
*/
|
|
812
|
-
private fun parseDotMeta(metaFile: File, quickLoadMode: Boolean): JSONObject? {
|
|
813
|
-
val metaContent = metaFile.readText(Charsets.UTF_8)
|
|
814
|
-
val json = JSONObject()
|
|
815
|
-
|
|
816
|
-
// Parse key: value pairs
|
|
817
|
-
for (line in metaContent.split(Regex("\r?\n"))) {
|
|
818
|
-
val colonIndex = line.indexOf(':')
|
|
819
|
-
if (colonIndex != -1) {
|
|
820
|
-
val fieldName = line.substring(0, colonIndex).trim()
|
|
821
|
-
val fieldValue = line.substring(colonIndex + 1).trim()
|
|
822
|
-
if (fieldName.isNotEmpty()) {
|
|
823
|
-
json.put(fieldName, fieldValue)
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
if (!json.has("title")) {
|
|
829
|
-
val metaName = metaFile.name
|
|
830
|
-
json.put("title", getTitleFromFilename(metaName.removeSuffix(".meta")))
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
// Find companion file
|
|
834
|
-
val companionPath = metaFile.absolutePath.removeSuffix(".meta")
|
|
835
|
-
val companionFile = File(companionPath)
|
|
836
|
-
|
|
837
|
-
if (companionFile.exists()) {
|
|
838
|
-
val tiddlerType = json.optString("type", "text/vnd.tiddlywiki")
|
|
839
|
-
val hasModuleType = json.has("module-type")
|
|
840
|
-
val hasPluginType = json.has("plugin-type")
|
|
841
|
-
|
|
842
|
-
// Determine whether this companion is a text file whose content
|
|
843
|
-
// should be loaded as the tiddler's "text" field.
|
|
844
|
-
// JS modules, CSS, JSON, and other text-based companions need their content.
|
|
845
|
-
// Binary companions (images, pdfs, etc.) should NOT have their content loaded;
|
|
846
|
-
// they use _canonical_uri instead (handled later by JS).
|
|
847
|
-
val isTextCompanion = companionPath.endsWith(".json") ||
|
|
848
|
-
companionPath.endsWith(".js") ||
|
|
849
|
-
companionPath.endsWith(".css") ||
|
|
850
|
-
companionPath.endsWith(".svg") ||
|
|
851
|
-
companionPath.endsWith(".txt") ||
|
|
852
|
-
companionPath.endsWith(".html") ||
|
|
853
|
-
companionPath.endsWith(".htm") ||
|
|
854
|
-
tiddlerType.startsWith("text/") ||
|
|
855
|
-
tiddlerType == "application/javascript" ||
|
|
856
|
-
tiddlerType == "application/json" ||
|
|
857
|
-
tiddlerType == "application/x-tiddler-dictionary"
|
|
858
|
-
|
|
859
|
-
if (isTextCompanion) {
|
|
860
|
-
val shouldIncludeText = if (quickLoadMode) {
|
|
861
|
-
shouldPreserveFullTextInQuickLoad(
|
|
862
|
-
json.optString("title", ""),
|
|
863
|
-
tiddlerType,
|
|
864
|
-
hasModuleType,
|
|
865
|
-
hasPluginType,
|
|
866
|
-
)
|
|
867
|
-
} else {
|
|
868
|
-
true
|
|
869
|
-
}
|
|
870
|
-
if (shouldIncludeText) {
|
|
871
|
-
val textContent = companionFile.readText(Charsets.UTF_8)
|
|
872
|
-
json.put("text", textContent)
|
|
873
|
-
} else {
|
|
874
|
-
json.put("_is_skinny", "yes")
|
|
875
|
-
}
|
|
876
|
-
}
|
|
877
|
-
// For binary companions (images, etc.), we don't set _canonical_uri here —
|
|
878
|
-
// that requires knowing the workspace base path. JS side handles it.
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
return if (json.has("title")) json else null
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
/**
|
|
885
|
-
* Decide whether a tiddler's full text should be included in the boot store.
|
|
886
|
-
* Mirrors the JS `shouldSaveFullTiddler()` logic.
|
|
887
|
-
*/
|
|
888
|
-
private fun shouldSaveFullTiddler(
|
|
889
|
-
title: String,
|
|
890
|
-
type: String,
|
|
891
|
-
hasModuleType: Boolean,
|
|
892
|
-
hasPluginType: Boolean,
|
|
893
|
-
estimatedTextLength: Int,
|
|
894
|
-
): Boolean {
|
|
895
|
-
if (shouldPreserveFullTextInQuickLoad(title, type, hasModuleType, hasPluginType)) return true
|
|
896
|
-
// Small tiddlers (< 10KB)
|
|
897
|
-
if (estimatedTextLength < 10000) return true
|
|
898
|
-
return false
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
private fun shouldPreserveFullTextInQuickLoad(
|
|
902
|
-
title: String,
|
|
903
|
-
type: String,
|
|
904
|
-
hasModuleType: Boolean,
|
|
905
|
-
hasPluginType: Boolean,
|
|
906
|
-
): Boolean {
|
|
907
|
-
if (title.startsWith("\$:/")) return true
|
|
908
|
-
if (type == "application/json" && hasPluginType) return true
|
|
909
|
-
if (hasModuleType) return true
|
|
910
|
-
return false
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
private fun getTitleFromFilename(filename: String): String {
|
|
914
|
-
return filename
|
|
915
|
-
.removeSuffix(".tid")
|
|
916
|
-
.removeSuffix(".json")
|
|
917
|
-
.removeSuffix(".meta")
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
// --- Tar helper functions ---
|
|
921
|
-
|
|
922
|
-
private fun readFully(stream: BufferedInputStream, buf: ByteArray): Int {
|
|
923
|
-
var offset = 0
|
|
924
|
-
while (offset < buf.size) {
|
|
925
|
-
val read = stream.read(buf, offset, buf.size - offset)
|
|
926
|
-
if (read <= 0) return offset
|
|
927
|
-
offset += read
|
|
928
|
-
}
|
|
929
|
-
return offset
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
private fun extractString(header: ByteArray, offset: Int, maxLen: Int): String {
|
|
933
|
-
val end = (offset until minOf(offset + maxLen, header.size))
|
|
934
|
-
.firstOrNull { header[it] == 0.toByte() }
|
|
935
|
-
?: (offset + maxLen)
|
|
936
|
-
return String(header, offset, end - offset, Charsets.UTF_8)
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
private fun skipDataBlocks(stream: BufferedInputStream, fileSize: Long) {
|
|
940
|
-
if (fileSize <= 0) return
|
|
941
|
-
// Data occupies ceil(fileSize / 512) * 512 bytes
|
|
942
|
-
val totalBytes = ((fileSize + 511) / 512) * 512
|
|
943
|
-
var skipped = 0L
|
|
944
|
-
while (skipped < totalBytes) {
|
|
945
|
-
val n = stream.skip(totalBytes - skipped)
|
|
946
|
-
if (n <= 0) break
|
|
947
|
-
skipped += n
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
}
|
|
1
|
+
package expo.modules.externalstorage
|
|
2
|
+
|
|
3
|
+
import expo.modules.kotlin.modules.Module
|
|
4
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Expo native module coordinator for external storage operations.
|
|
8
|
+
* Delegates to specialized helpers: FileSystem, TiddlyWikiParser, GitHelper.
|
|
9
|
+
*/
|
|
10
|
+
class ExternalStorageModule : Module() {
|
|
11
|
+
override fun definition() = ModuleDefinition {
|
|
12
|
+
Name("ExternalStorage")
|
|
13
|
+
|
|
14
|
+
// ─── Basic File Operations (FileSystem) ────────────────────────
|
|
15
|
+
|
|
16
|
+
AsyncFunction("exists") { path: String ->
|
|
17
|
+
FileSystem.exists(path)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
AsyncFunction("getInfo") { path: String ->
|
|
21
|
+
FileSystem.getInfo(path)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
AsyncFunction("mkdir") { path: String ->
|
|
25
|
+
FileSystem.mkdir(path)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
AsyncFunction("readDir") { path: String ->
|
|
29
|
+
FileSystem.readDir(path)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
AsyncFunction("readDirRecursive") { path: String ->
|
|
33
|
+
FileSystem.readDirRecursive(path)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
AsyncFunction("rmdir") { path: String ->
|
|
37
|
+
FileSystem.rmdir(path)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
AsyncFunction("readFileUtf8") { path: String ->
|
|
41
|
+
FileSystem.readFileUtf8(path)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
AsyncFunction("readFileBase64") { path: String ->
|
|
45
|
+
FileSystem.readFileBase64(path)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
AsyncFunction("writeFileUtf8") { path: String, content: String ->
|
|
49
|
+
FileSystem.writeFileUtf8(path, content)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
AsyncFunction("writeFileBase64") { path: String, base64Content: String ->
|
|
53
|
+
FileSystem.writeFileBase64(path, base64Content)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
AsyncFunction("appendFileBase64") { path: String, base64Content: String, truncateFirst: Boolean ->
|
|
57
|
+
FileSystem.appendFileBase64(path, base64Content, truncateFirst)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
AsyncFunction("writeFilesBase64") { paths: List<String>, base64Contents: List<String> ->
|
|
61
|
+
FileSystem.writeFilesBase64(paths, base64Contents)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
AsyncFunction("deleteFile") { path: String ->
|
|
65
|
+
FileSystem.deleteFile(path)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── Storage Permission Helpers (FileSystem) ────────────────────
|
|
69
|
+
|
|
70
|
+
AsyncFunction("isExternalStorageWritable") {
|
|
71
|
+
FileSystem.isExternalStorageWritable()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
AsyncFunction("getExternalStorageDirectory") {
|
|
75
|
+
FileSystem.getExternalStorageDirectory()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
AsyncFunction("isExternalStorageManager") {
|
|
79
|
+
FileSystem.isExternalStorageManager()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── HTTP Streaming (FileSystem) ────────────────────────────────
|
|
83
|
+
|
|
84
|
+
AsyncFunction("httpPostToFile") { url: String, headersMap: Map<String, String>, bodyBase64: String, destPath: String, contentType: String ->
|
|
85
|
+
FileSystem.httpPostToFile(url, headersMap, bodyBase64, destPath, contentType)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
AsyncFunction("readFileChunk") { path: String, offset: Long, length: Int ->
|
|
89
|
+
FileSystem.readFileChunk(path, offset, length)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
AsyncFunction("downloadFileResumable") { url: String, headersMap: Map<String, String>, destPath: String ->
|
|
93
|
+
FileSystem.downloadFileResumable(url, headersMap, destPath)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── Tar Extraction (FileSystem) ────────────────────────────────
|
|
97
|
+
|
|
98
|
+
AsyncFunction("extractTar") { tarPath: String, destDir: String ->
|
|
99
|
+
FileSystem.extractTar(tarPath, destDir)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── TiddlyWiki Parsing (TiddlyWikiParser) ──────────────────────
|
|
103
|
+
|
|
104
|
+
AsyncFunction("batchParseTidFiles") { filePaths: List<String>, quickLoadMode: Boolean ->
|
|
105
|
+
TiddlyWikiParser.batchParseTidFiles(filePaths, quickLoadMode)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ─── Git Operations (GitHelper facade) ──────────────────────────
|
|
109
|
+
|
|
110
|
+
AsyncFunction("gitStatus") { gitRootDir: String ->
|
|
111
|
+
GitHelper.gitStatus(gitRootDir)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
AsyncFunction("gitStatusDebug") { gitRootDir: String ->
|
|
115
|
+
GitHelper.gitStatusDebug(gitRootDir)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
AsyncFunction("buildGitIndex") { gitRootDir: String ->
|
|
119
|
+
GitHelper.buildGitIndex(gitRootDir)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
AsyncFunction("gitPush") { gitRootDir: String, remoteName: String, localBranch: String, remoteBranch: String, force: Boolean, headers: String? ->
|
|
123
|
+
GitHelper.gitPush(gitRootDir, remoteName, localBranch, remoteBranch, force, headers)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
AsyncFunction("gitCreateBundle") { gitRootDir: String, remoteName: String, localBranch: String, remoteBranch: String ->
|
|
127
|
+
GitHelper.gitCreateBundle(gitRootDir, remoteName, localBranch, remoteBranch)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
AsyncFunction("gitFetchFromBundle") { gitRootDir: String, bundleFileName: String, branch: String ->
|
|
131
|
+
GitHelper.gitFetchFromBundle(gitRootDir, bundleFileName, branch)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
AsyncFunction("gitFetch") { gitRootDir: String, remoteName: String, branch: String, headers: String? ->
|
|
135
|
+
GitHelper.gitFetch(gitRootDir, remoteName, branch, headers)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
AsyncFunction("gitCheckoutChangedFiles") { gitRootDir: String, oldOid: String, newOid: String ->
|
|
139
|
+
GitHelper.gitCheckoutChangedFiles(gitRootDir, oldOid, newOid)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
AsyncFunction("gitAddAndCommit") { gitRootDir: String, message: String, authorName: String, authorEmail: String ->
|
|
143
|
+
GitHelper.gitAddAndCommit(gitRootDir, message, authorName, authorEmail)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
AsyncFunction("gitReset") { gitRootDir: String, ref: String, mode: String ->
|
|
147
|
+
GitHelper.gitReset(gitRootDir, ref, mode)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
AsyncFunction("gitClone") { url: String, directory: String, branch: String?, depth: Int, singleBranch: Boolean, noTags: Boolean, headers: String? ->
|
|
151
|
+
GitHelper.gitClone(url, directory, branch, depth, singleBranch, noTags, headers)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
AsyncFunction("gitLog") { gitRootDir: String, ref: String?, maxCount: Int ->
|
|
155
|
+
GitHelper.gitLog(gitRootDir, ref, maxCount)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
AsyncFunction("gitResolveRef") { gitRootDir: String, ref: String ->
|
|
159
|
+
GitHelper.gitResolveRef(gitRootDir, ref)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
AsyncFunction("gitCurrentBranch") { gitRootDir: String ->
|
|
163
|
+
GitHelper.gitCurrentBranch(gitRootDir)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
AsyncFunction("gitInit") { directory: String, defaultBranch: String ->
|
|
167
|
+
GitHelper.gitInit(directory, defaultBranch)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
AsyncFunction("gitSetConfig") { gitRootDir: String, section: String, subsection: String?, name: String, value: String ->
|
|
171
|
+
GitHelper.gitSetConfig(gitRootDir, section, subsection, name, value)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
AsyncFunction("gitAddRemote") { gitRootDir: String, remoteName: String, url: String ->
|
|
175
|
+
GitHelper.gitAddRemote(gitRootDir, remoteName, url)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
AsyncFunction("gitReadBlob") { gitRootDir: String, ref: String, filepath: String, asBase64: Boolean ->
|
|
179
|
+
GitHelper.gitReadBlob(gitRootDir, ref, filepath, asBase64)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
AsyncFunction("gitDiffTrees") { gitRootDir: String, oldRef: String, newRef: String ->
|
|
183
|
+
GitHelper.gitDiffTrees(gitRootDir, oldRef, newRef)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
AsyncFunction("gitDiscardFileChanges") { gitRootDir: String, filepath: String ->
|
|
187
|
+
GitHelper.gitDiscardFileChanges(gitRootDir, filepath)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|