Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.2] - 2026-09-29

- We fixed an issue on iOS where a deep link that cold-started the app was not delivered to React Native, causing `Linking.getInitialURL()` to return `null`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -230,6 +231,85 @@ 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>(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 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<String, Any> {
return mapOf(
"DocumentDirectoryPath" to filesDir,
Expand Down
16 changes: 16 additions & 0 deletions android/src/main/java/com/mendixnative/fs/MxFileSystemModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,22 @@ 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)
}

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"
}
Expand Down
93 changes: 93 additions & 0 deletions example/__tests__/file-system.harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,91 @@ 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 with base64', async () => {
const filePath =
NativeFileSystem.relativeToDocumentsAbsolutePath('chunk-test.bin');

await NativeFileSystem.remove(filePath);

const data = new Uint8Array([1, 2, 3, 4, 5]);
const base64 = btoa(String.fromCharCode(...Array.from(data)));
await NativeFileSystem.writeChunk(base64, filePath, 0);

const size = await NativeFileSystem.getFileSize(filePath);
expect(size).toBe(5);

await NativeFileSystem.remove(filePath);
});

test('should append chunk at offset with base64', 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]);

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);

const size = await NativeFileSystem.getFileSize(filePath);
expect(size).toBe(10);

await NativeFileSystem.remove(filePath);
});

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);
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');
Expand All @@ -273,5 +358,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');
});
});
});
101 changes: 101 additions & 0 deletions ios/Modules/NativeFsModule/NativeFsModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,107 @@ 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
}

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)

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)
Expand Down
29 changes: 29 additions & 0 deletions ios/TurboModules/MxFileSystem/MxFileSystem.mm
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,33 @@ - (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];
}

- (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
Loading
Loading