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.
@@ -1,950 +1,190 @@
1
- package expo.modules.externalstorage
2
-
3
- import android.os.Build
4
- import android.os.Environment
5
- import android.util.Base64
6
- import expo.modules.kotlin.modules.Module
7
- import expo.modules.kotlin.modules.ModuleDefinition
8
- import okhttp3.Headers.Companion.toHeaders
9
- import okhttp3.MediaType.Companion.toMediaType
10
- import okhttp3.OkHttpClient
11
- import okhttp3.Request
12
- import okhttp3.RequestBody.Companion.toRequestBody
13
- import org.json.JSONArray
14
- import org.json.JSONObject
15
- import java.io.BufferedInputStream
16
- import java.io.ByteArrayOutputStream
17
- import java.io.File
18
- import java.io.FileInputStream
19
- import java.io.FileOutputStream
20
- import java.io.RandomAccessFile
21
- import java.util.concurrent.TimeUnit
22
-
23
- /**
24
- * Expo native module that performs raw java.io.File I/O on external storage.
25
- *
26
- * Expo's built-in FileSystem module restricts writes to its own directory
27
- * whitelist, blocking access to shared storage even when MANAGE_EXTERNAL_STORAGE
28
- * is granted. This module bypasses that restriction.
29
- *
30
- * All paths are plain filesystem paths (no file:// prefix).
31
- */
32
- class ExternalStorageModule : Module() {
33
- override fun definition() = ModuleDefinition {
34
- Name("ExternalStorage")
35
-
36
- // --- Basic queries ---
37
-
38
- AsyncFunction("exists") { path: String ->
39
- File(path).exists()
40
- }
41
-
42
- AsyncFunction("getInfo") { path: String ->
43
- val file = File(path)
44
- if (!file.exists()) {
45
- return@AsyncFunction mapOf(
46
- "exists" to false,
47
- "isDirectory" to false,
48
- "size" to 0L,
49
- "modificationTime" to 0L,
50
- )
51
- }
52
- mapOf(
53
- "exists" to true,
54
- "isDirectory" to file.isDirectory,
55
- "size" to file.length(),
56
- "modificationTime" to file.lastModified(),
57
- )
58
- }
59
-
60
- // --- Directory operations ---
61
-
62
- AsyncFunction("mkdir") { path: String ->
63
- val dir = File(path)
64
- if (!dir.exists()) {
65
- val ok = dir.mkdirs()
66
- if (!ok && !dir.exists()) {
67
- throw Exception("Failed to create directory: $path")
68
- }
69
- }
70
- }
71
-
72
- AsyncFunction("readDir") { path: String ->
73
- val dir = File(path)
74
- if (!dir.exists() || !dir.isDirectory) {
75
- throw Exception("ENOENT: no such directory: $path")
76
- }
77
- dir.list()?.toList() ?: emptyList<String>()
78
- }
79
-
80
- // Recursively list all files under a directory, returning paths relative to `path`.
81
- // Skips .git, node_modules, .DS_Store, output directories.
82
- AsyncFunction("readDirRecursive") { path: String ->
83
- val root = File(path)
84
- if (!root.exists() || !root.isDirectory) {
85
- throw Exception("ENOENT: no such directory: $path")
86
- }
87
- val skipNames = setOf(".git", "node_modules", ".DS_Store", "output")
88
- val result = mutableListOf<String>()
89
- fun walk(dir: File, prefix: String) {
90
- val children = dir.listFiles() ?: return
91
- for (child in children) {
92
- val relativePath = if (prefix.isEmpty()) child.name else "$prefix/${child.name}"
93
- if (child.isDirectory) {
94
- if (child.name !in skipNames) {
95
- walk(child, relativePath)
96
- }
97
- } else {
98
- result.add(relativePath)
99
- }
100
- }
101
- }
102
- walk(root, "")
103
- result
104
- }
105
-
106
- AsyncFunction("rmdir") { path: String ->
107
- val dir = File(path)
108
- if (dir.exists()) {
109
- dir.deleteRecursively()
110
- }
111
- }
112
-
113
- // --- File read/write ---
114
-
115
- AsyncFunction("readFileUtf8") { path: String ->
116
- val file = File(path)
117
- if (!file.exists()) {
118
- throw Exception("ENOENT: no such file: $path")
119
- }
120
- file.readText(Charsets.UTF_8)
121
- }
122
-
123
- AsyncFunction("readFileBase64") { path: String ->
124
- val file = File(path)
125
- if (!file.exists()) {
126
- throw Exception("ENOENT: no such file: $path")
127
- }
128
- Base64.encodeToString(file.readBytes(), Base64.NO_WRAP)
129
- }
130
-
131
- AsyncFunction("writeFileUtf8") { path: String, content: String ->
132
- val file = File(path)
133
- file.parentFile?.let { parent ->
134
- if (!parent.exists()) parent.mkdirs()
135
- }
136
- file.writeText(content, Charsets.UTF_8)
137
- }
138
-
139
- AsyncFunction("writeFileBase64") { path: String, base64Content: String ->
140
- val file = File(path)
141
- file.parentFile?.let { parent ->
142
- if (!parent.exists()) parent.mkdirs()
143
- }
144
- val bytes = Base64.decode(base64Content, Base64.DEFAULT)
145
- file.writeBytes(bytes)
146
- }
147
-
148
- /**
149
- * Append a Base64-encoded chunk to a file, optionally truncating it first.
150
- *
151
- * This is designed for **streaming large writes from JS in bounded-memory
152
- * chunks** (e.g. 512 KB per call). By keeping each chunk small the JVM
153
- * never needs to allocate the full file content at once, avoiding OOM on
154
- * 50+ MB git pack files.
155
- *
156
- * @param path Plain filesystem path
157
- * @param base64Content Chunk of data encoded as Base64
158
- * @param truncateFirst If true the file is created / truncated before
159
- * writing; pass true for the first chunk only.
160
- */
161
- AsyncFunction("appendFileBase64") { path: String, base64Content: String, truncateFirst: Boolean ->
162
- val file = File(path)
163
- file.parentFile?.let { parent ->
164
- if (!parent.exists()) parent.mkdirs()
165
- }
166
- val bytes = Base64.decode(base64Content, Base64.DEFAULT)
167
- // truncateFirst=true → overwrite (new file or truncate existing)
168
- // truncateFirst=false → append to existing file
169
- FileOutputStream(file, !truncateFirst).use { fos ->
170
- fos.write(bytes)
171
- }
172
- }
173
-
174
- AsyncFunction("writeFilesBase64") { paths: List<String>, base64Contents: List<String> ->
175
- if (paths.size != base64Contents.size) {
176
- throw Exception("paths/base64Contents length mismatch: ${paths.size} vs ${base64Contents.size}")
177
- }
178
-
179
- for (index in paths.indices) {
180
- val file = File(paths[index])
181
- file.parentFile?.let { parent ->
182
- if (!parent.exists()) parent.mkdirs()
183
- }
184
- val bytes = Base64.decode(base64Contents[index], Base64.DEFAULT)
185
- file.writeBytes(bytes)
186
- }
187
-
188
- mapOf("writtenCount" to paths.size)
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
+ }