expo-tiddlywiki-filesystem-android-external-storage 2.0.2

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/app.plugin.js ADDED
@@ -0,0 +1,25 @@
1
+ const { withAndroidManifest } = require('@expo/config-plugins');
2
+
3
+ const withExternalStoragePermission = (config) => {
4
+ return withAndroidManifest(config, async (config) => {
5
+ const androidManifest = config.modResults;
6
+
7
+ if (!androidManifest.manifest['uses-permission']) {
8
+ androidManifest.manifest['uses-permission'] = [];
9
+ }
10
+
11
+ // Add MANAGE_EXTERNAL_STORAGE permission
12
+ if (!androidManifest.manifest['uses-permission'].find(p => p.$['android:name'] === 'android.permission.MANAGE_EXTERNAL_STORAGE')) {
13
+ androidManifest.manifest['uses-permission'].push({
14
+ $: {
15
+ 'android:name': 'android.permission.MANAGE_EXTERNAL_STORAGE',
16
+ // Optionally add tools:ignore="ScopedStorage" if needed but usually standard permission is enough
17
+ },
18
+ });
19
+ }
20
+
21
+ return config;
22
+ });
23
+ };
24
+
25
+ module.exports = withExternalStoragePermission;
@@ -0,0 +1,130 @@
1
+ interface FileInfo {
2
+ exists: boolean;
3
+ isDirectory: boolean;
4
+ size: number;
5
+ /** Milliseconds since epoch */
6
+ modificationTime: number;
7
+ }
8
+ interface BatchWriteResult {
9
+ writtenCount: number;
10
+ }
11
+ interface HttpPostToFileResult {
12
+ statusCode: number;
13
+ headers: Record<string, string>;
14
+ bytesWritten: number;
15
+ }
16
+ interface DownloadFileResumableResult {
17
+ statusCode: number;
18
+ /** Final size of the file on disk after download */
19
+ totalBytes: number;
20
+ /** true if the download resumed from a partial file (HTTP 206) */
21
+ resumed: boolean;
22
+ }
23
+ interface ExtractTarResult {
24
+ filesExtracted: number;
25
+ }
26
+ interface ReadFileChunkResult {
27
+ /** Base64-encoded chunk data */
28
+ data: string;
29
+ bytesRead: number;
30
+ }
31
+ interface IExternalStorageModule {
32
+ exists(path: string): Promise<boolean>;
33
+ getInfo(path: string): Promise<FileInfo>;
34
+ mkdir(path: string): Promise<void>;
35
+ readDir(path: string): Promise<string[]>;
36
+ /** Recursively list all files under a directory, returning relative paths. Skips .git etc. */
37
+ readDirRecursive(path: string): Promise<string[]>;
38
+ rmdir(path: string): Promise<void>;
39
+ readFileUtf8(path: string): Promise<string>;
40
+ readFileBase64(path: string): Promise<string>;
41
+ writeFileUtf8(path: string, content: string): Promise<void>;
42
+ writeFileBase64(path: string, base64Content: string): Promise<void>;
43
+ /**
44
+ * Append a Base64-encoded chunk to a file, optionally truncating first.
45
+ *
46
+ * Designed for streaming large writes from JS in bounded-memory chunks
47
+ * (e.g. 512 KB each) so the JVM never allocates the full file content,
48
+ * avoiding OOM on 50+ MB git pack files.
49
+ *
50
+ * @param truncateFirst Pass `true` for the first chunk to create/truncate
51
+ * the file, then `false` for subsequent chunks.
52
+ */
53
+ appendFileBase64(path: string, base64Content: string, truncateFirst: boolean): Promise<void>;
54
+ writeFilesBase64(paths: string[], base64Contents: string[]): Promise<BatchWriteResult>;
55
+ deleteFile(path: string): Promise<void>;
56
+ isExternalStorageWritable(): Promise<boolean>;
57
+ getExternalStorageDirectory(): Promise<string>;
58
+ /** Android 11+ (API 30): check if MANAGE_EXTERNAL_STORAGE is granted. Pre-30 returns true. */
59
+ isExternalStorageManager(): Promise<boolean>;
60
+ /**
61
+ * HTTP POST with the response body streamed directly to a file on disk,
62
+ * **never buffering the full response in JVM/Hermes heap**.
63
+ *
64
+ * Designed for git-upload-pack which can return 100+ MB packfiles.
65
+ *
66
+ * @param url Target URL
67
+ * @param headers HTTP headers as `{ key: value }`
68
+ * @param bodyBase64 Request body encoded as Base64 (binary git protocol data)
69
+ * @param destPath Plain filesystem path to write the response body to
70
+ * @param contentType MIME type for the request body
71
+ */
72
+ httpPostToFile(url: string, headers: Record<string, string>, bodyBase64: string, destPath: string, contentType: string): Promise<HttpPostToFileResult>;
73
+ /**
74
+ * Read a chunk of a file starting at `offset` for up to `length` bytes.
75
+ * Returns Base64-encoded data and actual bytes read.
76
+ *
77
+ * Use this to stream a large file into JS in bounded-memory chunks.
78
+ */
79
+ readFileChunk(path: string, offset: number, length: number): Promise<ReadFileChunkResult>;
80
+ /**
81
+ * Download a file via HTTP GET with resumable download support.
82
+ *
83
+ * If `destPath` already exists on disk (from a previous interrupted download),
84
+ * sends `Range: bytes=<existingSize>-` to resume. The server must respond
85
+ * with 206 Partial Content for resume to work; otherwise the file is
86
+ * overwritten from scratch (200 response).
87
+ *
88
+ * @param url Target URL
89
+ * @param headers Extra HTTP headers (e.g. Authorization, ETag)
90
+ * @param destPath Plain filesystem path for the downloaded file
91
+ */
92
+ downloadFileResumable(url: string, headers: Record<string, string>, destPath: string): Promise<DownloadFileResumableResult>;
93
+ /**
94
+ * Extract an uncompressed tar archive to a destination directory.
95
+ * Uses a native tar parser — no third-party dependency.
96
+ * Supports POSIX ustar and GNU long-name extensions.
97
+ * Validates paths to prevent directory traversal attacks.
98
+ *
99
+ * @param tarPath Path to the .tar file
100
+ * @param destDir Destination directory (created if needed)
101
+ */
102
+ extractTar(tarPath: string, destDir: string): Promise<ExtractTarResult>;
103
+ /**
104
+ * Parse a batch of TiddlyWiki tiddler files entirely in native Kotlin.
105
+ *
106
+ * This is the critical performance optimization for initial wiki loading:
107
+ * a single bridge call processes 100+ files in parallel, returning a
108
+ * ready-to-inject JSON array string. Eliminates per-file bridge round-trips.
109
+ *
110
+ * Supports .tid, .json, and .meta files. Applies skinny logic:
111
+ * - System tiddlers ($:/) → always full text
112
+ * - Plugins (application/json + plugin-type) → always full text
113
+ * - Module tiddlers (module-type) → always full text
114
+ * - Small tiddlers (< 10KB body) → full text
115
+ * - Large user tiddlers → skinny (_is_skinny: "yes", text omitted)
116
+ *
117
+ * @param filePaths Array of absolute filesystem paths
118
+ * @param quickLoadMode If true, all tiddlers returned as skinny
119
+ * @returns JSON string: serialized array of tiddler field objects
120
+ */
121
+ batchParseTidFiles(filePaths: string[], quickLoadMode: boolean): Promise<string>;
122
+ }
123
+ export declare const ExternalStorage: IExternalStorageModule;
124
+ /**
125
+ * Strip file:// prefix from a URI to produce a plain filesystem path.
126
+ * Safe to call on paths that are already plain.
127
+ */
128
+ export declare function toPlainPath(uriOrPath: string): string;
129
+ export type { BatchWriteResult, DownloadFileResumableResult, ExtractTarResult, FileInfo, HttpPostToFileResult, IExternalStorageModule, ReadFileChunkResult };
130
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/build/index.js ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * TypeScript bindings for the ExternalStorage native module.
3
+ *
4
+ * This module uses raw java.io.File on Android to bypass Expo FileSystem's
5
+ * directory whitelist. It allows reading/writing to shared external storage
6
+ * when MANAGE_EXTERNAL_STORAGE permission is granted.
7
+ *
8
+ * All path arguments are plain filesystem paths (e.g. "/storage/emulated/0/Documents/TidGi/").
9
+ * Do NOT pass file:// URIs — strip the scheme before calling.
10
+ */
11
+ import { Platform } from 'react-native';
12
+ let _module;
13
+ /**
14
+ * Lazily load the native module. Wrapped in a function so that the app does NOT
15
+ * crash at import time if the native module is missing (e.g. on iOS or when the
16
+ * binary was built without it).
17
+ */
18
+ function getNativeModule() {
19
+ if (_module)
20
+ return _module;
21
+ if (Platform.OS !== 'android') {
22
+ throw new Error('ExternalStorage native module is only available on Android');
23
+ }
24
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
25
+ const { requireNativeModule } = require('expo-modules-core');
26
+ _module = requireNativeModule('ExternalStorage');
27
+ return _module;
28
+ }
29
+ export const ExternalStorage = new Proxy({}, {
30
+ get(_target, property) {
31
+ const mod = getNativeModule();
32
+ return mod[property];
33
+ },
34
+ });
35
+ /**
36
+ * Strip file:// prefix from a URI to produce a plain filesystem path.
37
+ * Safe to call on paths that are already plain.
38
+ */
39
+ export function toPlainPath(uriOrPath) {
40
+ if (uriOrPath.startsWith('file://')) {
41
+ return uriOrPath.slice('file://'.length);
42
+ }
43
+ return uriOrPath;
44
+ }
45
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAExC,IAAI,OAA2C,CAAC;AAEhD;;;;GAIG;AACH,SAAS,eAAe;IACtB,IAAI,OAAO;QAAE,OAAO,OAAO,CAAC;IAC5B,IAAI,QAAQ,CAAC,EAAE,KAAK,SAAS,EAAE,CAAC;QAC9B,MAAM,IAAI,KAAK,CAAC,4DAA4D,CAAC,CAAC;IAChF,CAAC;IACD,iEAAiE;IACjE,MAAM,EAAE,mBAAmB,EAAE,GAAG,OAAO,CAAC,mBAAmB,CAAsE,CAAC;IAClI,OAAO,GAAG,mBAAmB,CAAC,iBAAiB,CAAC,CAAC;IACjD,OAAO,OAAO,CAAC;AACjB,CAAC;AAqJD,MAAM,CAAC,MAAM,eAAe,GAA2B,IAAI,KAAK,CAAC,EAA4B,EAAE;IAC7F,GAAG,CAAC,OAAO,EAAE,QAAQ;QACnB,MAAM,GAAG,GAAG,eAAe,EAAE,CAAC;QAC9B,OAAQ,GAAmD,CAAC,QAAQ,CAAC,CAAC;IACxE,CAAC;CACF,CAAC,CAAC;AAEH;;;GAGG;AACH,MAAM,UAAU,WAAW,CAAC,SAAiB;IAC3C,IAAI,SAAS,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;QACpC,OAAO,SAAS,CAAC,KAAK,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC;IAC3C,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC","sourcesContent":["/**\n * TypeScript bindings for the ExternalStorage native module.\n *\n * This module uses raw java.io.File on Android to bypass Expo FileSystem's\n * directory whitelist. It allows reading/writing to shared external storage\n * when MANAGE_EXTERNAL_STORAGE permission is granted.\n *\n * All path arguments are plain filesystem paths (e.g. \"/storage/emulated/0/Documents/TidGi/\").\n * Do NOT pass file:// URIs — strip the scheme before calling.\n */\nimport { Platform } from 'react-native';\n\nlet _module: IExternalStorageModule | undefined;\n\n/**\n * Lazily load the native module. Wrapped in a function so that the app does NOT\n * crash at import time if the native module is missing (e.g. on iOS or when the\n * binary was built without it).\n */\nfunction getNativeModule(): IExternalStorageModule {\n if (_module) return _module;\n if (Platform.OS !== 'android') {\n throw new Error('ExternalStorage native module is only available on Android');\n }\n // eslint-disable-next-line @typescript-eslint/no-require-imports\n const { requireNativeModule } = require('expo-modules-core') as { requireNativeModule: (name: string) => IExternalStorageModule };\n _module = requireNativeModule('ExternalStorage');\n return _module;\n}\n\ninterface FileInfo {\n exists: boolean;\n isDirectory: boolean;\n size: number;\n /** Milliseconds since epoch */\n modificationTime: number;\n}\n\ninterface BatchWriteResult {\n writtenCount: number;\n}\n\ninterface HttpPostToFileResult {\n statusCode: number;\n headers: Record<string, string>;\n bytesWritten: number;\n}\n\ninterface DownloadFileResumableResult {\n statusCode: number;\n /** Final size of the file on disk after download */\n totalBytes: number;\n /** true if the download resumed from a partial file (HTTP 206) */\n resumed: boolean;\n}\n\ninterface ExtractTarResult {\n filesExtracted: number;\n}\n\ninterface ReadFileChunkResult {\n /** Base64-encoded chunk data */\n data: string;\n bytesRead: number;\n}\n\ninterface IExternalStorageModule {\n exists(path: string): Promise<boolean>;\n getInfo(path: string): Promise<FileInfo>;\n\n mkdir(path: string): Promise<void>;\n readDir(path: string): Promise<string[]>;\n /** Recursively list all files under a directory, returning relative paths. Skips .git etc. */\n readDirRecursive(path: string): Promise<string[]>;\n rmdir(path: string): Promise<void>;\n\n readFileUtf8(path: string): Promise<string>;\n readFileBase64(path: string): Promise<string>;\n writeFileUtf8(path: string, content: string): Promise<void>;\n writeFileBase64(path: string, base64Content: string): Promise<void>;\n /**\n * Append a Base64-encoded chunk to a file, optionally truncating first.\n *\n * Designed for streaming large writes from JS in bounded-memory chunks\n * (e.g. 512 KB each) so the JVM never allocates the full file content,\n * avoiding OOM on 50+ MB git pack files.\n *\n * @param truncateFirst Pass `true` for the first chunk to create/truncate\n * the file, then `false` for subsequent chunks.\n */\n appendFileBase64(path: string, base64Content: string, truncateFirst: boolean): Promise<void>;\n writeFilesBase64(paths: string[], base64Contents: string[]): Promise<BatchWriteResult>;\n deleteFile(path: string): Promise<void>;\n\n isExternalStorageWritable(): Promise<boolean>;\n getExternalStorageDirectory(): Promise<string>;\n /** Android 11+ (API 30): check if MANAGE_EXTERNAL_STORAGE is granted. Pre-30 returns true. */\n isExternalStorageManager(): Promise<boolean>;\n\n /**\n * HTTP POST with the response body streamed directly to a file on disk,\n * **never buffering the full response in JVM/Hermes heap**.\n *\n * Designed for git-upload-pack which can return 100+ MB packfiles.\n *\n * @param url Target URL\n * @param headers HTTP headers as `{ key: value }`\n * @param bodyBase64 Request body encoded as Base64 (binary git protocol data)\n * @param destPath Plain filesystem path to write the response body to\n * @param contentType MIME type for the request body\n */\n httpPostToFile(\n url: string,\n headers: Record<string, string>,\n bodyBase64: string,\n destPath: string,\n contentType: string,\n ): Promise<HttpPostToFileResult>;\n\n /**\n * Read a chunk of a file starting at `offset` for up to `length` bytes.\n * Returns Base64-encoded data and actual bytes read.\n *\n * Use this to stream a large file into JS in bounded-memory chunks.\n */\n readFileChunk(path: string, offset: number, length: number): Promise<ReadFileChunkResult>;\n\n /**\n * Download a file via HTTP GET with resumable download support.\n *\n * If `destPath` already exists on disk (from a previous interrupted download),\n * sends `Range: bytes=<existingSize>-` to resume. The server must respond\n * with 206 Partial Content for resume to work; otherwise the file is\n * overwritten from scratch (200 response).\n *\n * @param url Target URL\n * @param headers Extra HTTP headers (e.g. Authorization, ETag)\n * @param destPath Plain filesystem path for the downloaded file\n */\n downloadFileResumable(\n url: string,\n headers: Record<string, string>,\n destPath: string,\n ): Promise<DownloadFileResumableResult>;\n\n /**\n * Extract an uncompressed tar archive to a destination directory.\n * Uses a native tar parser — no third-party dependency.\n * Supports POSIX ustar and GNU long-name extensions.\n * Validates paths to prevent directory traversal attacks.\n *\n * @param tarPath Path to the .tar file\n * @param destDir Destination directory (created if needed)\n */\n extractTar(tarPath: string, destDir: string): Promise<ExtractTarResult>;\n\n /**\n * Parse a batch of TiddlyWiki tiddler files entirely in native Kotlin.\n *\n * This is the critical performance optimization for initial wiki loading:\n * a single bridge call processes 100+ files in parallel, returning a\n * ready-to-inject JSON array string. Eliminates per-file bridge round-trips.\n *\n * Supports .tid, .json, and .meta files. Applies skinny logic:\n * - System tiddlers ($:/) → always full text\n * - Plugins (application/json + plugin-type) → always full text\n * - Module tiddlers (module-type) → always full text\n * - Small tiddlers (< 10KB body) → full text\n * - Large user tiddlers → skinny (_is_skinny: \"yes\", text omitted)\n *\n * @param filePaths Array of absolute filesystem paths\n * @param quickLoadMode If true, all tiddlers returned as skinny\n * @returns JSON string: serialized array of tiddler field objects\n */\n batchParseTidFiles(filePaths: string[], quickLoadMode: boolean): Promise<string>;\n}\n\nexport const ExternalStorage: IExternalStorageModule = new Proxy({} as IExternalStorageModule, {\n get(_target, property) {\n const mod = getNativeModule();\n return (mod as unknown as Record<string | symbol, unknown>)[property];\n },\n});\n\n/**\n * Strip file:// prefix from a URI to produce a plain filesystem path.\n * Safe to call on paths that are already plain.\n */\nexport function toPlainPath(uriOrPath: string): string {\n if (uriOrPath.startsWith('file://')) {\n return uriOrPath.slice('file://'.length);\n }\n return uriOrPath;\n}\n\nexport type { BatchWriteResult, DownloadFileResumableResult, ExtractTarResult, FileInfo, HttpPostToFileResult, IExternalStorageModule, ReadFileChunkResult };\n"]}
@@ -0,0 +1,6 @@
1
+ {
2
+ "platforms": ["android"],
3
+ "android": {
4
+ "modules": ["expo.modules.externalstorage.ExternalStorageModule"]
5
+ }
6
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "expo-tiddlywiki-filesystem-android-external-storage",
3
+ "version": "2.0.2",
4
+ "description": "Expo native module for TidGi-Mobile: external storage I/O + TiddlyWiki .tid/.meta/.json batch parsing in Kotlin",
5
+ "main": "build/index.js",
6
+ "types": "build/index.d.ts",
7
+ "plugin": "app.plugin.js",
8
+ "scripts": {
9
+ "build": "tsc -p tsconfig.json",
10
+ "clean": "expo-module clean",
11
+ "lint": "expo-module lint",
12
+ "test": "expo-module test",
13
+ "prepare": "tsc -p tsconfig.json"
14
+ },
15
+ "keywords": [
16
+ "react-native",
17
+ "expo",
18
+ "filesystem",
19
+ "android",
20
+ "external-storage"
21
+ ],
22
+ "author": "TidGi Mobile Team",
23
+ "license": "MIT",
24
+ "peerDependencies": {
25
+ "expo": "*",
26
+ "react": "*",
27
+ "react-native": "*"
28
+ },
29
+ "devDependencies": {
30
+ "@types/react": "^18.2.0",
31
+ "@types/react-native": "^0.73.0",
32
+ "expo": "^50.0.0",
33
+ "expo-module-scripts": "^3.0.0",
34
+ "expo-modules-core": "^1.11.0",
35
+ "react": "^18.2.0",
36
+ "react-native": "^0.73.0",
37
+ "typescript": "^5.0.0"
38
+ },
39
+ "files": [
40
+ "build",
41
+ "android",
42
+ "expo-module.config.json",
43
+ "src",
44
+ "plugin.js",
45
+ "app.plugin.js"
46
+ ],
47
+ "dependencies": {
48
+ "@expo/config-plugins": "^54.0.4"
49
+ }
50
+ }
package/plugin.js ADDED
@@ -0,0 +1,25 @@
1
+ const { withAndroidManifest } = require('@expo/config-plugins');
2
+
3
+ const withExternalStoragePermission = (config) => {
4
+ return withAndroidManifest(config, async (config) => {
5
+ const androidManifest = config.modResults;
6
+
7
+ if (!androidManifest.manifest['uses-permission']) {
8
+ androidManifest.manifest['uses-permission'] = [];
9
+ }
10
+
11
+ // Add MANAGE_EXTERNAL_STORAGE permission
12
+ if (!androidManifest.manifest['uses-permission'].find(p => p.$['android:name'] === 'android.permission.MANAGE_EXTERNAL_STORAGE')) {
13
+ androidManifest.manifest['uses-permission'].push({
14
+ $: {
15
+ 'android:name': 'android.permission.MANAGE_EXTERNAL_STORAGE',
16
+ // Optionally add tools:ignore="ScopedStorage" if needed but usually standard permission is enough
17
+ },
18
+ });
19
+ }
20
+
21
+ return config;
22
+ });
23
+ };
24
+
25
+ module.exports = withExternalStoragePermission;
package/src/index.ts ADDED
@@ -0,0 +1,196 @@
1
+ /**
2
+ * TypeScript bindings for the ExternalStorage native module.
3
+ *
4
+ * This module uses raw java.io.File on Android to bypass Expo FileSystem's
5
+ * directory whitelist. It allows reading/writing to shared external storage
6
+ * when MANAGE_EXTERNAL_STORAGE permission is granted.
7
+ *
8
+ * All path arguments are plain filesystem paths (e.g. "/storage/emulated/0/Documents/TidGi/").
9
+ * Do NOT pass file:// URIs — strip the scheme before calling.
10
+ */
11
+ import { Platform } from 'react-native';
12
+
13
+ let _module: IExternalStorageModule | undefined;
14
+
15
+ /**
16
+ * Lazily load the native module. Wrapped in a function so that the app does NOT
17
+ * crash at import time if the native module is missing (e.g. on iOS or when the
18
+ * binary was built without it).
19
+ */
20
+ function getNativeModule(): IExternalStorageModule {
21
+ if (_module) return _module;
22
+ if (Platform.OS !== 'android') {
23
+ throw new Error('ExternalStorage native module is only available on Android');
24
+ }
25
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
26
+ const { requireNativeModule } = require('expo-modules-core') as { requireNativeModule: (name: string) => IExternalStorageModule };
27
+ _module = requireNativeModule('ExternalStorage');
28
+ return _module;
29
+ }
30
+
31
+ interface FileInfo {
32
+ exists: boolean;
33
+ isDirectory: boolean;
34
+ size: number;
35
+ /** Milliseconds since epoch */
36
+ modificationTime: number;
37
+ }
38
+
39
+ interface BatchWriteResult {
40
+ writtenCount: number;
41
+ }
42
+
43
+ interface HttpPostToFileResult {
44
+ statusCode: number;
45
+ headers: Record<string, string>;
46
+ bytesWritten: number;
47
+ }
48
+
49
+ interface DownloadFileResumableResult {
50
+ statusCode: number;
51
+ /** Final size of the file on disk after download */
52
+ totalBytes: number;
53
+ /** true if the download resumed from a partial file (HTTP 206) */
54
+ resumed: boolean;
55
+ }
56
+
57
+ interface ExtractTarResult {
58
+ filesExtracted: number;
59
+ }
60
+
61
+ interface ReadFileChunkResult {
62
+ /** Base64-encoded chunk data */
63
+ data: string;
64
+ bytesRead: number;
65
+ }
66
+
67
+ interface IExternalStorageModule {
68
+ exists(path: string): Promise<boolean>;
69
+ getInfo(path: string): Promise<FileInfo>;
70
+
71
+ mkdir(path: string): Promise<void>;
72
+ readDir(path: string): Promise<string[]>;
73
+ /** Recursively list all files under a directory, returning relative paths. Skips .git etc. */
74
+ readDirRecursive(path: string): Promise<string[]>;
75
+ rmdir(path: string): Promise<void>;
76
+
77
+ readFileUtf8(path: string): Promise<string>;
78
+ readFileBase64(path: string): Promise<string>;
79
+ writeFileUtf8(path: string, content: string): Promise<void>;
80
+ writeFileBase64(path: string, base64Content: string): Promise<void>;
81
+ /**
82
+ * Append a Base64-encoded chunk to a file, optionally truncating first.
83
+ *
84
+ * Designed for streaming large writes from JS in bounded-memory chunks
85
+ * (e.g. 512 KB each) so the JVM never allocates the full file content,
86
+ * avoiding OOM on 50+ MB git pack files.
87
+ *
88
+ * @param truncateFirst Pass `true` for the first chunk to create/truncate
89
+ * the file, then `false` for subsequent chunks.
90
+ */
91
+ appendFileBase64(path: string, base64Content: string, truncateFirst: boolean): Promise<void>;
92
+ writeFilesBase64(paths: string[], base64Contents: string[]): Promise<BatchWriteResult>;
93
+ deleteFile(path: string): Promise<void>;
94
+
95
+ isExternalStorageWritable(): Promise<boolean>;
96
+ getExternalStorageDirectory(): Promise<string>;
97
+ /** Android 11+ (API 30): check if MANAGE_EXTERNAL_STORAGE is granted. Pre-30 returns true. */
98
+ isExternalStorageManager(): Promise<boolean>;
99
+
100
+ /**
101
+ * HTTP POST with the response body streamed directly to a file on disk,
102
+ * **never buffering the full response in JVM/Hermes heap**.
103
+ *
104
+ * Designed for git-upload-pack which can return 100+ MB packfiles.
105
+ *
106
+ * @param url Target URL
107
+ * @param headers HTTP headers as `{ key: value }`
108
+ * @param bodyBase64 Request body encoded as Base64 (binary git protocol data)
109
+ * @param destPath Plain filesystem path to write the response body to
110
+ * @param contentType MIME type for the request body
111
+ */
112
+ httpPostToFile(
113
+ url: string,
114
+ headers: Record<string, string>,
115
+ bodyBase64: string,
116
+ destPath: string,
117
+ contentType: string,
118
+ ): Promise<HttpPostToFileResult>;
119
+
120
+ /**
121
+ * Read a chunk of a file starting at `offset` for up to `length` bytes.
122
+ * Returns Base64-encoded data and actual bytes read.
123
+ *
124
+ * Use this to stream a large file into JS in bounded-memory chunks.
125
+ */
126
+ readFileChunk(path: string, offset: number, length: number): Promise<ReadFileChunkResult>;
127
+
128
+ /**
129
+ * Download a file via HTTP GET with resumable download support.
130
+ *
131
+ * If `destPath` already exists on disk (from a previous interrupted download),
132
+ * sends `Range: bytes=<existingSize>-` to resume. The server must respond
133
+ * with 206 Partial Content for resume to work; otherwise the file is
134
+ * overwritten from scratch (200 response).
135
+ *
136
+ * @param url Target URL
137
+ * @param headers Extra HTTP headers (e.g. Authorization, ETag)
138
+ * @param destPath Plain filesystem path for the downloaded file
139
+ */
140
+ downloadFileResumable(
141
+ url: string,
142
+ headers: Record<string, string>,
143
+ destPath: string,
144
+ ): Promise<DownloadFileResumableResult>;
145
+
146
+ /**
147
+ * Extract an uncompressed tar archive to a destination directory.
148
+ * Uses a native tar parser — no third-party dependency.
149
+ * Supports POSIX ustar and GNU long-name extensions.
150
+ * Validates paths to prevent directory traversal attacks.
151
+ *
152
+ * @param tarPath Path to the .tar file
153
+ * @param destDir Destination directory (created if needed)
154
+ */
155
+ extractTar(tarPath: string, destDir: string): Promise<ExtractTarResult>;
156
+
157
+ /**
158
+ * Parse a batch of TiddlyWiki tiddler files entirely in native Kotlin.
159
+ *
160
+ * This is the critical performance optimization for initial wiki loading:
161
+ * a single bridge call processes 100+ files in parallel, returning a
162
+ * ready-to-inject JSON array string. Eliminates per-file bridge round-trips.
163
+ *
164
+ * Supports .tid, .json, and .meta files. Applies skinny logic:
165
+ * - System tiddlers ($:/) → always full text
166
+ * - Plugins (application/json + plugin-type) → always full text
167
+ * - Module tiddlers (module-type) → always full text
168
+ * - Small tiddlers (< 10KB body) → full text
169
+ * - Large user tiddlers → skinny (_is_skinny: "yes", text omitted)
170
+ *
171
+ * @param filePaths Array of absolute filesystem paths
172
+ * @param quickLoadMode If true, all tiddlers returned as skinny
173
+ * @returns JSON string: serialized array of tiddler field objects
174
+ */
175
+ batchParseTidFiles(filePaths: string[], quickLoadMode: boolean): Promise<string>;
176
+ }
177
+
178
+ export const ExternalStorage: IExternalStorageModule = new Proxy({} as IExternalStorageModule, {
179
+ get(_target, property) {
180
+ const mod = getNativeModule();
181
+ return (mod as unknown as Record<string | symbol, unknown>)[property];
182
+ },
183
+ });
184
+
185
+ /**
186
+ * Strip file:// prefix from a URI to produce a plain filesystem path.
187
+ * Safe to call on paths that are already plain.
188
+ */
189
+ export function toPlainPath(uriOrPath: string): string {
190
+ if (uriOrPath.startsWith('file://')) {
191
+ return uriOrPath.slice('file://'.length);
192
+ }
193
+ return uriOrPath;
194
+ }
195
+
196
+ export type { BatchWriteResult, DownloadFileResumableResult, ExtractTarResult, FileInfo, HttpPostToFileResult, IExternalStorageModule, ReadFileChunkResult };