From 6b626546c73a238aa00af5786e072c3fc551aeed Mon Sep 17 00:00:00 2001 From: MxKevinBeqo Date: Tue, 15 Sep 2026 14:03:15 +0200 Subject: [PATCH 1/8] feat: add FS APIs for getFileSize and writeChunk --- .../mendixnative/react/fs/FileBackend.kt | 16 ++++++ .../mendixnative/react/fs/NativeFsModule.kt | 39 +++++++++++++ .../com/mendixnative/fs/MxFileSystemModule.kt | 8 +++ .../NativeFsModule/NativeFsModule.swift | 56 +++++++++++++++++++ ios/TurboModules/MxFileSystem/MxFileSystem.mm | 14 +++++ src/file-system/NativeMxFileSystem.ts | 6 ++ src/file-system/index.ts | 5 ++ 7 files changed, 144 insertions(+) diff --git a/android/src/main/java/com/mendix/mendixnative/react/fs/FileBackend.kt b/android/src/main/java/com/mendix/mendixnative/react/fs/FileBackend.kt index 8bfd54b..c1643b9 100644 --- a/android/src/main/java/com/mendix/mendixnative/react/fs/FileBackend.kt +++ b/android/src/main/java/com/mendix/mendixnative/react/fs/FileBackend.kt @@ -121,6 +121,22 @@ class FileBackend(val context: Context) { return directory.list() ?: emptyArray() } + fun getFileSize(filePath: String): Long { + val file = File(filePath) + return if (file.exists()) file.length() else 0L + } + + @Throws(IOException::class) + fun writeChunk(data: ByteArray, filePath: String, offset: Long) { + val file = File(filePath) + file.parentFile?.mkdirs() + + RandomAccessFile(file, "rw").use { raf -> + raf.seek(offset) + raf.write(data) + } + } + fun exists(filePath: String): Boolean { return File(filePath).exists() } diff --git a/android/src/main/java/com/mendix/mendixnative/react/fs/NativeFsModule.kt b/android/src/main/java/com/mendix/mendixnative/react/fs/NativeFsModule.kt index 96a8f29..c61a395 100644 --- a/android/src/main/java/com/mendix/mendixnative/react/fs/NativeFsModule.kt +++ b/android/src/main/java/com/mendix/mendixnative/react/fs/NativeFsModule.kt @@ -230,6 +230,45 @@ class NativeFsModule(private val reactContext: ReactApplicationContext) { } } + fun getFileSize(filePath: String, promise: Promise) { + try { + val size = fileBackend.getFileSize(ensureWhiteListedPath(filePath)) + promise.resolve(size.toDouble()) + } catch (e: PathNotAccessibleException) { + e.printStackTrace() + promise.reject(INVALID_PATH, e) + } + } + + fun writeChunk(blob: ReadableMap, filePath: String, offset: Double, promise: Promise) { + val blobModule = reactContext.nativeModule(BlobModule.NAME) + val blobId: String = blob.getString("blobId") ?: run { + promise.reject(ERROR_INVALID_BLOB, "The specified blob is invalid") + return + } + + val bytes = blobModule!!.resolve(blobId, blob.getInt("offset"), blob.getInt("size")) + if (bytes == null) { + promise.reject(ERROR_INVALID_BLOB, "The specified blob is invalid") + return + } + + try { + fileBackend.writeChunk(bytes, ensureWhiteListedPath(filePath), offset.toLong()) + } catch (e: IOException) { + e.printStackTrace() + promise.reject(ERROR_CACHE_FAILED, "Failed writing chunk to disk") + return + } catch (e: PathNotAccessibleException) { + e.printStackTrace() + promise.reject(INVALID_PATH, e) + return + } + + blobModule.release(blobId) + promise.resolve(null) + } + fun getConstants(): Map { return mapOf( "DocumentDirectoryPath" to filesDir, diff --git a/android/src/main/java/com/mendixnative/fs/MxFileSystemModule.kt b/android/src/main/java/com/mendixnative/fs/MxFileSystemModule.kt index 4d87099..3af2bdf 100644 --- a/android/src/main/java/com/mendixnative/fs/MxFileSystemModule.kt +++ b/android/src/main/java/com/mendixnative/fs/MxFileSystemModule.kt @@ -63,6 +63,14 @@ class MxFileSystemModule(reactContext: ReactApplicationContext) : fsModule.setEncryptionEnabled(enabled) } + override fun getFileSize(filePath: String, promise: Promise) { + fsModule.getFileSize(filePath, promise) + } + + override fun writeChunk(blob: ReadableMap, filePath: String, offset: Double, promise: Promise) { + fsModule.writeChunk(blob, filePath, offset, promise) + } + companion object { const val NAME = "MxFileSystem" } diff --git a/ios/Modules/NativeFsModule/NativeFsModule.swift b/ios/Modules/NativeFsModule/NativeFsModule.swift index 0d47252..955f706 100644 --- a/ios/Modules/NativeFsModule/NativeFsModule.swift +++ b/ios/Modules/NativeFsModule/NativeFsModule.swift @@ -268,6 +268,62 @@ public class NativeFsModule: NSObject { } } + public func getFileSize(_ filePath: String, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock) { + + guard isWhiteListedPath(filePath, reject: reject) else { return } + + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: filePath) else { + resolve(NSNumber(value: 0)) + return + } + + do { + let attributes = try fileManager.attributesOfItem(atPath: filePath) + let size = attributes[.size] as? UInt64 ?? 0 + resolve(NSNumber(value: size)) + } catch { + reject(NativeFsModule.ERROR_READ_FAILED, NativeFsModule.formatError("Failed to get file size"), error) + } + } + + public func writeChunk(_ blob: [String: Any], + filePath: String, + offset: Double, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock) { + + guard isWhiteListedPath(filePath, reject: reject) else { return } + + guard let data = readBlobRefAsData(blob) else { + reject(NativeFsModule.ERROR_READ_FAILED, NativeFsModule.formatError("Failed to read blob"), nil) + return + } + + let fileManager = FileManager.default + let byteOffset = UInt64(offset) + + do { + if !fileManager.fileExists(atPath: filePath) { + let directoryURL = URL(fileURLWithPath: (filePath as NSString).deletingLastPathComponent) + try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) + fileManager.createFile(atPath: filePath, contents: nil, attributes: nil) + } + + let fileHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: filePath)) + defer { try? fileHandle.close() } + + try fileHandle.seek(toOffset: byteOffset) + fileHandle.write(data) + + resolve(nil) + } catch { + reject(NativeFsModule.ERROR_SAVE_FAILED, NativeFsModule.formatError("Failed to write chunk"), error) + } + } + private func isWhiteListedPath(_ paths: String..., reject: RCTPromiseRejectBlock) -> Bool { do { try NativeFsModule.ensureWhiteListedPath(paths) diff --git a/ios/TurboModules/MxFileSystem/MxFileSystem.mm b/ios/TurboModules/MxFileSystem/MxFileSystem.mm index c31fb5a..9cafb1b 100644 --- a/ios/TurboModules/MxFileSystem/MxFileSystem.mm +++ b/ios/TurboModules/MxFileSystem/MxFileSystem.mm @@ -93,4 +93,18 @@ - (void)setEncryptionEnabled:(BOOL)enabled { [[[NativeFsModule alloc] init] setEncryptionEnabled:enabled]; } +- (void)getFileSize:(nonnull NSString *)filePath + resolve:(nonnull RCTPromiseResolveBlock)resolve + reject:(nonnull RCTPromiseRejectBlock)reject { + [[[NativeFsModule alloc] init] getFileSize:filePath resolve:resolve reject:reject]; +} + +- (void)writeChunk:(nonnull NSDictionary *)blob + filePath:(nonnull NSString *)filePath + offset:(double)offset + resolve:(nonnull RCTPromiseResolveBlock)resolve + reject:(nonnull RCTPromiseRejectBlock)reject { + [[[NativeFsModule alloc] init] writeChunk:blob filePath:filePath offset:offset resolve:resolve reject:reject]; +} + @end diff --git a/src/file-system/NativeMxFileSystem.ts b/src/file-system/NativeMxFileSystem.ts index 6845bc4..392933d 100644 --- a/src/file-system/NativeMxFileSystem.ts +++ b/src/file-system/NativeMxFileSystem.ts @@ -32,6 +32,12 @@ export interface Spec extends TurboModule { writeJson(data: CodegenTypes.UnsafeObject, filepath: string): Promise; readJson(filepath: string): Promise; setEncryptionEnabled(enabled: boolean): void; + getFileSize(filePath: string): Promise; + writeChunk( + blob: CodegenTypes.UnsafeObject, + filePath: string, + offset: number + ): Promise; } export default TurboModuleRegistry.getEnforcing('MxFileSystem'); diff --git a/src/file-system/index.ts b/src/file-system/index.ts index e9704b9..2c0269e 100644 --- a/src/file-system/index.ts +++ b/src/file-system/index.ts @@ -31,6 +31,11 @@ const initFs = () => { readJson: (filepath: string) => NativeMxFileSystem.readJson(filepath) as Promise, + //Methods - file operations required for resumable downloads + getFileSize: NativeMxFileSystem.getFileSize, + writeChunk: (blob: BlobData, filePath: string, offset: number) => + NativeMxFileSystem.writeChunk(blob, filePath, offset), + //Helpers relativeToDocumentsAbsolutePath: (path: string) => path.startsWith(docDirPath) ? path : [docDirPath, path].join('/'), From 66e9ba3fdac7b0b0a5f8e2c6205a39953f781b83 Mon Sep 17 00:00:00 2001 From: MxKevinBeqo Date: Tue, 15 Sep 2026 14:04:03 +0200 Subject: [PATCH 2/8] test: add harness tests --- example/__tests__/file-system.harness.ts | 102 +++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/example/__tests__/file-system.harness.ts b/example/__tests__/file-system.harness.ts index ca8b769..92df4a4 100644 --- a/example/__tests__/file-system.harness.ts +++ b/example/__tests__/file-system.harness.ts @@ -257,6 +257,100 @@ describe('NativeFileSystem', () => { }); }); + describe('getFileSize', () => { + test('should return 0 for non-existent file', async () => { + const filePath = NativeFileSystem.relativeToDocumentsAbsolutePath( + 'non-existent-size.bin' + ); + const size = await NativeFileSystem.getFileSize(filePath); + expect(size).toBe(0); + }); + + test('should return correct size for existing file', async () => { + const filePath = + NativeFileSystem.relativeToDocumentsAbsolutePath('size-test.json'); + const testData = { hello: 'world' }; + await NativeFileSystem.writeJson(testData, filePath); + + const size = await NativeFileSystem.getFileSize(filePath); + expect(size).toBeGreaterThan(0); + + await NativeFileSystem.remove(filePath); + }); + + test('should throw for non white listed path', async () => { + try { + await NativeFileSystem.getFileSize('invalid-path.bin'); + expect(true).toBe(false); + } catch (error: any) { + const errorMessage = + 'Path needs to be an absolute path to the apps accessible space.'; + expect(error.message).contains(errorMessage); + } + }); + }); + + describe('writeChunk', () => { + test('should create file and write first chunk', async () => { + const filePath = + NativeFileSystem.relativeToDocumentsAbsolutePath('chunk-test.bin'); + + await NativeFileSystem.remove(filePath); + + const data = new Uint8Array([1, 2, 3, 4, 5]); + const blob = new Blob([data as any]) as any; + await NativeFileSystem.writeChunk(blob.data, filePath, 0); + + const size = await NativeFileSystem.getFileSize(filePath); + expect(size).toBe(5); + + await NativeFileSystem.remove(filePath); + }); + + test('should append chunk at offset', async () => { + const filePath = NativeFileSystem.relativeToDocumentsAbsolutePath( + 'chunk-append-test.bin' + ); + + await NativeFileSystem.remove(filePath); + + const chunk1 = new Uint8Array([1, 2, 3, 4, 5]); + const chunk2 = new Uint8Array([6, 7, 8, 9, 10]); + + await NativeFileSystem.writeChunk( + (new Blob([chunk1 as any]) as any).data, + filePath, + 0 + ); + await NativeFileSystem.writeChunk( + (new Blob([chunk2 as any]) as any).data, + filePath, + 5 + ); + + const size = await NativeFileSystem.getFileSize(filePath); + expect(size).toBe(10); + + await NativeFileSystem.remove(filePath); + }); + + test('should throw for non white listed path', async () => { + const data = new Uint8Array([1, 2, 3]); + try { + await NativeFileSystem.writeChunk( + (new Blob([data as any]) as any).data, + 'invalid-path.bin', + 0 + ); + expect(true).toBe(false); + } catch (error: any) { + const errorMessage = + 'Path needs to be an absolute path to the apps accessible space.'; + expect(error.message).contains(errorMessage); + } + }); + }); + describe('API methods exist', () => { test('should have read method', () => { expect(typeof NativeFileSystem.read).toBe('function'); @@ -273,5 +367,13 @@ describe('NativeFileSystem', () => { test('should have save method', () => { expect(typeof NativeFileSystem.save).toBe('function'); }); + + test('should have getFileSize method', () => { + expect(typeof NativeFileSystem.getFileSize).toBe('function'); + }); + + test('should have writeChunk method', () => { + expect(typeof NativeFileSystem.writeChunk).toBe('function'); + }); }); }); From 79aa922ba0488eeeb646c3c573a5b5655ad3f259 Mon Sep 17 00:00:00 2001 From: MxKevinBeqo Date: Tue, 15 Sep 2026 14:05:38 +0200 Subject: [PATCH 3/8] chore: add changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ca8802..3108eb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- We added two new File System APIs to get the size of a File and write chunks via offset (used in 'resume download' functionality). + ## [v0.6.1] - 2026-09-22 - We made the MxConfiguration.nativeBinaryVersion property public. From 5d6640c5a1f37b41bee9cc9fbdb9d972eb02d9e0 Mon Sep 17 00:00:00 2001 From: MxKevinBeqo Date: Mon, 28 Sep 2026 14:10:30 +0200 Subject: [PATCH 4/8] test: fix test cases --- example/__tests__/file-system.harness.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/example/__tests__/file-system.harness.ts b/example/__tests__/file-system.harness.ts index 92df4a4..e004659 100644 --- a/example/__tests__/file-system.harness.ts +++ b/example/__tests__/file-system.harness.ts @@ -298,7 +298,8 @@ describe('NativeFileSystem', () => { await NativeFileSystem.remove(filePath); const data = new Uint8Array([1, 2, 3, 4, 5]); - const blob = new Blob([data as any]) as any; + const dataString = String.fromCharCode(...Array.from(data)); + const blob = new Blob([dataString]) as any; await NativeFileSystem.writeChunk(blob.data, filePath, 0); const size = await NativeFileSystem.getFileSize(filePath); @@ -317,13 +318,16 @@ describe('NativeFileSystem', () => { const chunk1 = new Uint8Array([1, 2, 3, 4, 5]); const chunk2 = new Uint8Array([6, 7, 8, 9, 10]); + const dataString1 = String.fromCharCode(...Array.from(chunk1)); + const dataString2 = String.fromCharCode(...Array.from(chunk2)); + await NativeFileSystem.writeChunk( - (new Blob([chunk1 as any]) as any).data, + (new Blob([dataString1]) as any).data, filePath, 0 ); await NativeFileSystem.writeChunk( - (new Blob([chunk2 as any]) as any).data, + (new Blob([dataString2]) as any).data, filePath, 5 ); @@ -336,9 +340,10 @@ describe('NativeFileSystem', () => { test('should throw for non white listed path', async () => { const data = new Uint8Array([1, 2, 3]); + const dataString = String.fromCharCode(...Array.from(data)); try { await NativeFileSystem.writeChunk( - (new Blob([data as any]) as any).data, + (new Blob([dataString]) as any).data, 'invalid-path.bin', 0 ); From 9f1ada4fc1c0aa6e87c26dd3f5cdff47cf70c74a Mon Sep 17 00:00:00 2001 From: MxKevinBeqo Date: Wed, 30 Sep 2026 13:42:36 +0200 Subject: [PATCH 5/8] feat: add support for base64 argument --- .../mendixnative/react/fs/NativeFsModule.kt | 41 +++++++++++++++++ .../com/mendixnative/fs/MxFileSystemModule.kt | 8 ++++ example/__tests__/file-system.harness.ts | 34 +++++--------- .../NativeFsModule/NativeFsModule.swift | 45 +++++++++++++++++++ ios/TurboModules/MxFileSystem/MxFileSystem.mm | 15 +++++++ src/file-system/NativeMxFileSystem.ts | 6 +++ src/file-system/index.ts | 12 +++-- 7 files changed, 133 insertions(+), 28 deletions(-) diff --git a/android/src/main/java/com/mendix/mendixnative/react/fs/NativeFsModule.kt b/android/src/main/java/com/mendix/mendixnative/react/fs/NativeFsModule.kt index c61a395..ebf61e5 100644 --- a/android/src/main/java/com/mendix/mendixnative/react/fs/NativeFsModule.kt +++ b/android/src/main/java/com/mendix/mendixnative/react/fs/NativeFsModule.kt @@ -14,6 +14,7 @@ import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.databind.JsonMappingException import com.fasterxml.jackson.databind.ObjectMapper import com.mendix.mendixnative.react.nativeModule +import android.util.Base64 import java.io.File import java.io.FileNotFoundException import java.io.IOException @@ -269,6 +270,46 @@ class NativeFsModule(private val reactContext: ReactApplicationContext) { promise.resolve(null) } + fun saveBase64(base64: String, filePath: String, promise: Promise) { + val bytes = try { + Base64.decode(base64, Base64.DEFAULT) + } catch (e: IllegalArgumentException) { + promise.reject(ERROR_INVALID_BLOB, "Invalid base64 string") + return + } + + try { + fileBackend.save(bytes, ensureWhiteListedPath(filePath)) + promise.resolve(null) + } catch (e: IOException) { + e.printStackTrace() + promise.reject(ERROR_CACHE_FAILED, "Failed writing file to disk") + } catch (e: PathNotAccessibleException) { + e.printStackTrace() + promise.reject(INVALID_PATH, e) + } + } + + fun writeChunkBase64(base64: String, filePath: String, offset: Double, promise: Promise) { + val bytes = try { + Base64.decode(base64, Base64.DEFAULT) + } catch (e: IllegalArgumentException) { + promise.reject(ERROR_INVALID_BLOB, "Invalid base64 string") + return + } + + try { + fileBackend.writeChunk(bytes, ensureWhiteListedPath(filePath), offset.toLong()) + promise.resolve(null) + } catch (e: IOException) { + e.printStackTrace() + promise.reject(ERROR_CACHE_FAILED, "Failed writing chunk to disk") + } catch (e: PathNotAccessibleException) { + e.printStackTrace() + promise.reject(INVALID_PATH, e) + } + } + fun getConstants(): Map { return mapOf( "DocumentDirectoryPath" to filesDir, diff --git a/android/src/main/java/com/mendixnative/fs/MxFileSystemModule.kt b/android/src/main/java/com/mendixnative/fs/MxFileSystemModule.kt index 3af2bdf..b40fe83 100644 --- a/android/src/main/java/com/mendixnative/fs/MxFileSystemModule.kt +++ b/android/src/main/java/com/mendixnative/fs/MxFileSystemModule.kt @@ -71,6 +71,14 @@ class MxFileSystemModule(reactContext: ReactApplicationContext) : fsModule.writeChunk(blob, filePath, offset, promise) } + override fun saveBase64(base64: String, filePath: String, promise: Promise) { + fsModule.saveBase64(base64, filePath, promise) + } + + override fun writeChunkBase64(base64: String, filePath: String, offset: Double, promise: Promise) { + fsModule.writeChunkBase64(base64, filePath, offset, promise) + } + companion object { const val NAME = "MxFileSystem" } diff --git a/example/__tests__/file-system.harness.ts b/example/__tests__/file-system.harness.ts index e004659..b2c0c63 100644 --- a/example/__tests__/file-system.harness.ts +++ b/example/__tests__/file-system.harness.ts @@ -291,16 +291,15 @@ describe('NativeFileSystem', () => { }); describe('writeChunk', () => { - test('should create file and write first chunk', async () => { + test('should create file and write first chunk with base64', async () => { const filePath = NativeFileSystem.relativeToDocumentsAbsolutePath('chunk-test.bin'); await NativeFileSystem.remove(filePath); const data = new Uint8Array([1, 2, 3, 4, 5]); - const dataString = String.fromCharCode(...Array.from(data)); - const blob = new Blob([dataString]) as any; - await NativeFileSystem.writeChunk(blob.data, filePath, 0); + const base64 = btoa(String.fromCharCode(...Array.from(data))); + await NativeFileSystem.writeChunk(base64, filePath, 0); const size = await NativeFileSystem.getFileSize(filePath); expect(size).toBe(5); @@ -308,7 +307,7 @@ describe('NativeFileSystem', () => { await NativeFileSystem.remove(filePath); }); - test('should append chunk at offset', async () => { + test('should append chunk at offset with base64', async () => { const filePath = NativeFileSystem.relativeToDocumentsAbsolutePath( 'chunk-append-test.bin' ); @@ -318,19 +317,11 @@ describe('NativeFileSystem', () => { const chunk1 = new Uint8Array([1, 2, 3, 4, 5]); const chunk2 = new Uint8Array([6, 7, 8, 9, 10]); - const dataString1 = String.fromCharCode(...Array.from(chunk1)); - const dataString2 = String.fromCharCode(...Array.from(chunk2)); + const base64Chunk1 = btoa(String.fromCharCode(...Array.from(chunk1))); + const base64Chunk2 = btoa(String.fromCharCode(...Array.from(chunk2))); - await NativeFileSystem.writeChunk( - (new Blob([dataString1]) as any).data, - filePath, - 0 - ); - await NativeFileSystem.writeChunk( - (new Blob([dataString2]) as any).data, - filePath, - 5 - ); + await NativeFileSystem.writeChunk(base64Chunk1, filePath, 0); + await NativeFileSystem.writeChunk(base64Chunk2, filePath, 5); const size = await NativeFileSystem.getFileSize(filePath); expect(size).toBe(10); @@ -339,14 +330,9 @@ describe('NativeFileSystem', () => { }); test('should throw for non white listed path', async () => { - const data = new Uint8Array([1, 2, 3]); - const dataString = String.fromCharCode(...Array.from(data)); + const base64 = btoa(String.fromCharCode(1, 2, 3)); try { - await NativeFileSystem.writeChunk( - (new Blob([dataString]) as any).data, - 'invalid-path.bin', - 0 - ); + await NativeFileSystem.writeChunk(base64, 'invalid-path.bin', 0); expect(true).toBe(false); } catch (error: any) { const errorMessage = diff --git a/ios/Modules/NativeFsModule/NativeFsModule.swift b/ios/Modules/NativeFsModule/NativeFsModule.swift index 955f706..0adc582 100644 --- a/ios/Modules/NativeFsModule/NativeFsModule.swift +++ b/ios/Modules/NativeFsModule/NativeFsModule.swift @@ -302,6 +302,51 @@ public class NativeFsModule: NSObject { return } + writeChunkData(data, filePath: filePath, offset: offset, resolve: resolve, reject: reject) + } + + public func saveBase64(_ base64: String, + filepath: String, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock) { + + guard isWhiteListedPath(filepath, reject: reject) else { return } + + guard let data = Data(base64Encoded: base64) else { + reject(NativeFsModule.ERROR_READ_FAILED, NativeFsModule.formatError("Invalid base64 string"), nil) + return + } + + do { + try NativeFsModule.save(data, filepath: filepath) + resolve(nil) + } catch { + reject(NativeFsModule.ERROR_SAVE_FAILED, NativeFsModule.formatError("Save failed"), error) + } + } + + public func writeChunkBase64(_ base64: String, + filePath: String, + offset: Double, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock) { + + guard isWhiteListedPath(filePath, reject: reject) else { return } + + guard let data = Data(base64Encoded: base64) else { + reject(NativeFsModule.ERROR_READ_FAILED, NativeFsModule.formatError("Invalid base64 string"), nil) + return + } + + writeChunkData(data, filePath: filePath, offset: offset, resolve: resolve, reject: reject) + } + + private func writeChunkData(_ data: Data, + filePath: String, + offset: Double, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock) { + let fileManager = FileManager.default let byteOffset = UInt64(offset) diff --git a/ios/TurboModules/MxFileSystem/MxFileSystem.mm b/ios/TurboModules/MxFileSystem/MxFileSystem.mm index 9cafb1b..d98e3d3 100644 --- a/ios/TurboModules/MxFileSystem/MxFileSystem.mm +++ b/ios/TurboModules/MxFileSystem/MxFileSystem.mm @@ -107,4 +107,19 @@ - (void)writeChunk:(nonnull NSDictionary *)blob [[[NativeFsModule alloc] init] writeChunk:blob filePath:filePath offset:offset resolve:resolve reject:reject]; } +- (void)saveBase64:(nonnull NSString *)base64 + filePath:(nonnull NSString *)filePath + resolve:(nonnull RCTPromiseResolveBlock)resolve + reject:(nonnull RCTPromiseRejectBlock)reject { + [[[NativeFsModule alloc] init] saveBase64:base64 filepath:filePath resolve:resolve reject:reject]; +} + +- (void)writeChunkBase64:(nonnull NSString *)base64 + filePath:(nonnull NSString *)filePath + offset:(double)offset + resolve:(nonnull RCTPromiseResolveBlock)resolve + reject:(nonnull RCTPromiseRejectBlock)reject { + [[[NativeFsModule alloc] init] writeChunkBase64:base64 filePath:filePath offset:offset resolve:resolve reject:reject]; +} + @end diff --git a/src/file-system/NativeMxFileSystem.ts b/src/file-system/NativeMxFileSystem.ts index 392933d..bab7311 100644 --- a/src/file-system/NativeMxFileSystem.ts +++ b/src/file-system/NativeMxFileSystem.ts @@ -38,6 +38,12 @@ export interface Spec extends TurboModule { filePath: string, offset: number ): Promise; + saveBase64(base64: string, filePath: string): Promise; + writeChunkBase64( + base64: string, + filePath: string, + offset: number + ): Promise; } export default TurboModuleRegistry.getEnforcing('MxFileSystem'); diff --git a/src/file-system/index.ts b/src/file-system/index.ts index 2c0269e..21f298c 100644 --- a/src/file-system/index.ts +++ b/src/file-system/index.ts @@ -24,8 +24,10 @@ const initFs = () => { setEncryptionEnabled: NativeMxFileSystem.setEncryptionEnabled, //Methods - signature modified since specs does not recognize Record and generics - save: (blob: BlobData, filePath: string) => - NativeMxFileSystem.save(blob, filePath), + save: (data: BlobData | string, filePath: string) => + typeof data === 'string' + ? NativeMxFileSystem.saveBase64(data, filePath) + : NativeMxFileSystem.save(data, filePath), writeJson: (data: Record, filepath: string) => NativeMxFileSystem.writeJson(data, filepath), readJson: (filepath: string) => @@ -33,8 +35,10 @@ const initFs = () => { //Methods - file operations required for resumable downloads getFileSize: NativeMxFileSystem.getFileSize, - writeChunk: (blob: BlobData, filePath: string, offset: number) => - NativeMxFileSystem.writeChunk(blob, filePath, offset), + writeChunk: (data: BlobData | string, filePath: string, offset: number) => + typeof data === 'string' + ? NativeMxFileSystem.writeChunkBase64(data, filePath, offset) + : NativeMxFileSystem.writeChunk(data, filePath, offset), //Helpers relativeToDocumentsAbsolutePath: (path: string) => From 60509b69e130aaacf69d2d4b38c4eb50a41093e8 Mon Sep 17 00:00:00 2001 From: MxKevinBeqo Date: Wed, 7 Oct 2026 10:47:56 +0200 Subject: [PATCH 6/8] fix: reject getFileSize when file is missing or size is unavailable Previously getFileSize resolved 0 for a non-existent file and when the size attribute could not be read, making a missing file indistinguishable from an empty one. It now rejects with ERROR_READ_FAILED on both platforms. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- .../mendix/mendixnative/react/fs/FileBackend.kt | 9 ++++++++- .../mendixnative/react/fs/NativeFsModule.kt | 5 +++++ example/__tests__/file-system.harness.ts | 17 ++++++++++++++++- ios/Modules/NativeFsModule/NativeFsModule.swift | 7 +++++-- 5 files changed, 35 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 966d9d5..e1de119 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- We added two new File System APIs to get the size of a File and write chunks via offset (used in 'resume download' functionality). +- We added two new File System APIs to get the size of a File and write chunks via offset (used in 'resume download' functionality). `getFileSize` rejects when the file does not exist or its size cannot be determined. ## [v0.6.2] - 2026-09-29 diff --git a/android/src/main/java/com/mendix/mendixnative/react/fs/FileBackend.kt b/android/src/main/java/com/mendix/mendixnative/react/fs/FileBackend.kt index c1643b9..5174f12 100644 --- a/android/src/main/java/com/mendix/mendixnative/react/fs/FileBackend.kt +++ b/android/src/main/java/com/mendix/mendixnative/react/fs/FileBackend.kt @@ -121,9 +121,16 @@ class FileBackend(val context: Context) { return directory.list() ?: emptyArray() } + @Throws(IOException::class) fun getFileSize(filePath: String): Long { val file = File(filePath) - return if (file.exists()) file.length() else 0L + if (!file.exists()) { + throw FileNotFoundException("File does not exist") + } + if (!file.isFile) { + throw IOException("Path is not a file") + } + return file.length() } @Throws(IOException::class) diff --git a/android/src/main/java/com/mendix/mendixnative/react/fs/NativeFsModule.kt b/android/src/main/java/com/mendix/mendixnative/react/fs/NativeFsModule.kt index ebf61e5..88919af 100644 --- a/android/src/main/java/com/mendix/mendixnative/react/fs/NativeFsModule.kt +++ b/android/src/main/java/com/mendix/mendixnative/react/fs/NativeFsModule.kt @@ -235,6 +235,11 @@ class NativeFsModule(private val reactContext: ReactApplicationContext) { try { val size = fileBackend.getFileSize(ensureWhiteListedPath(filePath)) promise.resolve(size.toDouble()) + } catch (_: FileNotFoundException) { + promise.reject(ERROR_READ_FAILED, "File does not exist") + } catch (e: IOException) { + e.printStackTrace() + promise.reject(ERROR_READ_FAILED, "Failed to get file size") } catch (e: PathNotAccessibleException) { e.printStackTrace() promise.reject(INVALID_PATH, e) diff --git a/example/__tests__/file-system.harness.ts b/example/__tests__/file-system.harness.ts index b2c0c63..c228f63 100644 --- a/example/__tests__/file-system.harness.ts +++ b/example/__tests__/file-system.harness.ts @@ -258,12 +258,27 @@ describe('NativeFileSystem', () => { }); describe('getFileSize', () => { - test('should return 0 for non-existent file', async () => { + test('should throw for non-existent file', async () => { const filePath = NativeFileSystem.relativeToDocumentsAbsolutePath( 'non-existent-size.bin' ); + try { + await NativeFileSystem.getFileSize(filePath); + expect(true).toBe(false); + } catch (error: any) { + expect(error.message).contains('File does not exist'); + } + }); + + test('should return 0 for existing empty file', async () => { + const filePath = + NativeFileSystem.relativeToDocumentsAbsolutePath('empty-size.bin'); + await NativeFileSystem.writeChunk('', filePath, 0); + const size = await NativeFileSystem.getFileSize(filePath); expect(size).toBe(0); + + await NativeFileSystem.remove(filePath); }); test('should return correct size for existing file', async () => { diff --git a/ios/Modules/NativeFsModule/NativeFsModule.swift b/ios/Modules/NativeFsModule/NativeFsModule.swift index 0adc582..b58e683 100644 --- a/ios/Modules/NativeFsModule/NativeFsModule.swift +++ b/ios/Modules/NativeFsModule/NativeFsModule.swift @@ -276,13 +276,16 @@ public class NativeFsModule: NSObject { let fileManager = FileManager.default guard fileManager.fileExists(atPath: filePath) else { - resolve(NSNumber(value: 0)) + reject(NativeFsModule.ERROR_READ_FAILED, NativeFsModule.formatError("File does not exist"), nil) return } do { let attributes = try fileManager.attributesOfItem(atPath: filePath) - let size = attributes[.size] as? UInt64 ?? 0 + guard let size = (attributes[.size] as? NSNumber)?.uint64Value else { + reject(NativeFsModule.ERROR_READ_FAILED, NativeFsModule.formatError("Failed to get file size"), nil) + return + } resolve(NSNumber(value: size)) } catch { reject(NativeFsModule.ERROR_READ_FAILED, NativeFsModule.formatError("Failed to get file size"), error) From abb657cf5e70595d79654dd87c09f0fe6324ceba Mon Sep 17 00:00:00 2001 From: MxKevinBeqo Date: Wed, 7 Oct 2026 10:48:06 +0200 Subject: [PATCH 7/8] refactor: expose base64 save and writeChunk as separate APIs Instead of overloading save and writeChunk to accept BlobData | string, add saveBase64 and writeChunkBase64 to NativeFileSystem. save and writeChunk accept a blob only again. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- example/__tests__/file-system.harness.ts | 51 ++++++++++++++++++++---- src/file-system/index.ts | 16 ++++---- 3 files changed, 52 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1de119..2f35374 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- We added two new File System APIs to get the size of a File and write chunks via offset (used in 'resume download' functionality). `getFileSize` rejects when the file does not exist or its size cannot be determined. +- We added new File System APIs to get the size of a File (`getFileSize`) and write chunks via offset (`writeChunk`), used in 'resume download' functionality. Base64 string variants of `save` and `writeChunk` are available as `saveBase64` and `writeChunkBase64`. `getFileSize` rejects when the file does not exist or its size cannot be determined. ## [v0.6.2] - 2026-09-29 diff --git a/example/__tests__/file-system.harness.ts b/example/__tests__/file-system.harness.ts index c228f63..6225dae 100644 --- a/example/__tests__/file-system.harness.ts +++ b/example/__tests__/file-system.harness.ts @@ -273,7 +273,7 @@ describe('NativeFileSystem', () => { test('should return 0 for existing empty file', async () => { const filePath = NativeFileSystem.relativeToDocumentsAbsolutePath('empty-size.bin'); - await NativeFileSystem.writeChunk('', filePath, 0); + await NativeFileSystem.writeChunkBase64('', filePath, 0); const size = await NativeFileSystem.getFileSize(filePath); expect(size).toBe(0); @@ -305,8 +305,35 @@ describe('NativeFileSystem', () => { }); }); - describe('writeChunk', () => { - test('should create file and write first chunk with base64', async () => { + describe('saveBase64', () => { + test('should save decoded base64 data to file', async () => { + const filePath = + NativeFileSystem.relativeToDocumentsAbsolutePath('save-base64.bin'); + + const base64 = btoa(String.fromCharCode(1, 2, 3, 4)); + await NativeFileSystem.saveBase64(base64, filePath); + + const size = await NativeFileSystem.getFileSize(filePath); + expect(size).toBe(4); + + await NativeFileSystem.remove(filePath); + }); + + test('should throw for non white listed path', async () => { + const base64 = btoa(String.fromCharCode(1, 2, 3)); + try { + await NativeFileSystem.saveBase64(base64, 'invalid-path.bin'); + expect(true).toBe(false); + } catch (error: any) { + const errorMessage = + 'Path needs to be an absolute path to the apps accessible space.'; + expect(error.message).contains(errorMessage); + } + }); + }); + + describe('writeChunkBase64', () => { + test('should create file and write first chunk', async () => { const filePath = NativeFileSystem.relativeToDocumentsAbsolutePath('chunk-test.bin'); @@ -314,7 +341,7 @@ describe('NativeFileSystem', () => { const data = new Uint8Array([1, 2, 3, 4, 5]); const base64 = btoa(String.fromCharCode(...Array.from(data))); - await NativeFileSystem.writeChunk(base64, filePath, 0); + await NativeFileSystem.writeChunkBase64(base64, filePath, 0); const size = await NativeFileSystem.getFileSize(filePath); expect(size).toBe(5); @@ -322,7 +349,7 @@ describe('NativeFileSystem', () => { await NativeFileSystem.remove(filePath); }); - test('should append chunk at offset with base64', async () => { + test('should append chunk at offset', async () => { const filePath = NativeFileSystem.relativeToDocumentsAbsolutePath( 'chunk-append-test.bin' ); @@ -335,8 +362,8 @@ describe('NativeFileSystem', () => { const base64Chunk1 = btoa(String.fromCharCode(...Array.from(chunk1))); const base64Chunk2 = btoa(String.fromCharCode(...Array.from(chunk2))); - await NativeFileSystem.writeChunk(base64Chunk1, filePath, 0); - await NativeFileSystem.writeChunk(base64Chunk2, filePath, 5); + await NativeFileSystem.writeChunkBase64(base64Chunk1, filePath, 0); + await NativeFileSystem.writeChunkBase64(base64Chunk2, filePath, 5); const size = await NativeFileSystem.getFileSize(filePath); expect(size).toBe(10); @@ -347,7 +374,7 @@ describe('NativeFileSystem', () => { test('should throw for non white listed path', async () => { const base64 = btoa(String.fromCharCode(1, 2, 3)); try { - await NativeFileSystem.writeChunk(base64, 'invalid-path.bin', 0); + await NativeFileSystem.writeChunkBase64(base64, 'invalid-path.bin', 0); expect(true).toBe(false); } catch (error: any) { const errorMessage = @@ -378,8 +405,16 @@ describe('NativeFileSystem', () => { expect(typeof NativeFileSystem.getFileSize).toBe('function'); }); + test('should have saveBase64 method', () => { + expect(typeof NativeFileSystem.saveBase64).toBe('function'); + }); + test('should have writeChunk method', () => { expect(typeof NativeFileSystem.writeChunk).toBe('function'); }); + + test('should have writeChunkBase64 method', () => { + expect(typeof NativeFileSystem.writeChunkBase64).toBe('function'); + }); }); }); diff --git a/src/file-system/index.ts b/src/file-system/index.ts index 21f298c..e459e45 100644 --- a/src/file-system/index.ts +++ b/src/file-system/index.ts @@ -24,10 +24,8 @@ const initFs = () => { setEncryptionEnabled: NativeMxFileSystem.setEncryptionEnabled, //Methods - signature modified since specs does not recognize Record and generics - save: (data: BlobData | string, filePath: string) => - typeof data === 'string' - ? NativeMxFileSystem.saveBase64(data, filePath) - : NativeMxFileSystem.save(data, filePath), + save: (blob: BlobData, filePath: string) => + NativeMxFileSystem.save(blob, filePath), writeJson: (data: Record, filepath: string) => NativeMxFileSystem.writeJson(data, filepath), readJson: (filepath: string) => @@ -35,10 +33,12 @@ const initFs = () => { //Methods - file operations required for resumable downloads getFileSize: NativeMxFileSystem.getFileSize, - writeChunk: (data: BlobData | string, filePath: string, offset: number) => - typeof data === 'string' - ? NativeMxFileSystem.writeChunkBase64(data, filePath, offset) - : NativeMxFileSystem.writeChunk(data, filePath, offset), + writeChunk: (blob: BlobData, filePath: string, offset: number) => + NativeMxFileSystem.writeChunk(blob, filePath, offset), + + //Methods - base64 string variants of save and writeChunk + saveBase64: NativeMxFileSystem.saveBase64, + writeChunkBase64: NativeMxFileSystem.writeChunkBase64, //Helpers relativeToDocumentsAbsolutePath: (path: string) => From 89a387430a423ce4b076d278c2c5a7ed024ff3bd Mon Sep 17 00:00:00 2001 From: MxKevinBeqo Date: Wed, 7 Oct 2026 14:38:10 +0200 Subject: [PATCH 8/8] test: verify saveBase64 content instead of on-disk size With encryption enabled, saveBase64 goes through the regular save path and the stored file is larger than the payload on Android, so asserting on getFileSize failed. Read the file back with readAsDataURL instead. --- example/__tests__/file-system.harness.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/example/__tests__/file-system.harness.ts b/example/__tests__/file-system.harness.ts index 6225dae..51620d8 100644 --- a/example/__tests__/file-system.harness.ts +++ b/example/__tests__/file-system.harness.ts @@ -313,8 +313,11 @@ describe('NativeFileSystem', () => { const base64 = btoa(String.fromCharCode(1, 2, 3, 4)); await NativeFileSystem.saveBase64(base64, filePath); - const size = await NativeFileSystem.getFileSize(filePath); - expect(size).toBe(4); + // Not asserting on getFileSize: saveBase64 goes through the regular save + // path, so with encryption enabled the on-disk size includes overhead. + // readAsDataURL decrypts, so it verifies the stored content. + const dataUrl = await NativeFileSystem.readAsDataURL(filePath); + expect(dataUrl).contains(base64); await NativeFileSystem.remove(filePath); });