From 3c8128ecb382b9582bb21cc86f3823f0f9fce695 Mon Sep 17 00:00:00 2001 From: MxKevinBeqo Date: Tue, 15 Sep 2026 14:03:15 +0200 Subject: [PATCH 1/3] 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 70b1fced5dfbb74a0197e7ea10ee28ca20e955f3 Mon Sep 17 00:00:00 2001 From: MxKevinBeqo Date: Tue, 15 Sep 2026 14:04:03 +0200 Subject: [PATCH 2/3] 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 2a6f5365f0e2e5d6e2cfa06a37f202c14af2e4ec Mon Sep 17 00:00:00 2001 From: MxKevinBeqo Date: Tue, 15 Sep 2026 14:05:38 +0200 Subject: [PATCH 3/3] chore: add changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ac6382..5cc2152 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.0] - 2026-07-29 - We replaced deprecated React Native APIs with modern supported APIs.