Skip to content
Open
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 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.

## [v1.0.0] - 2026-11-04

- We upgraded the react-native to version 0.88.0 and bumped related dependencies.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,29 @@ class FileBackend(val context: Context) {
return directory.list() ?: emptyArray()
}

@Throws(IOException::class)
fun getFileSize(filePath: String): Long {
val file = File(filePath)
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)
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 @@ -237,6 +238,90 @@ class NativeFsModule(private val reactContext: ReactApplicationContext) {
}
}

fun getFileSize(filePath: String, promise: Promise) {
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)
}
}

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
146 changes: 146 additions & 0 deletions example/__tests__/file-system.harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,136 @@ describe('NativeFileSystem', () => {
});
});

describe('getFileSize', () => {
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.writeChunkBase64('', 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 () => {
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('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);

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

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

await NativeFileSystem.remove(filePath);

const data = new Uint8Array([1, 2, 3, 4, 5]);
const base64 = btoa(String.fromCharCode(...Array.from(data)));
await NativeFileSystem.writeChunkBase64(base64, 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]);

const base64Chunk1 = btoa(String.fromCharCode(...Array.from(chunk1)));
const base64Chunk2 = btoa(String.fromCharCode(...Array.from(chunk2)));

await NativeFileSystem.writeChunkBase64(base64Chunk1, filePath, 0);
await NativeFileSystem.writeChunkBase64(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.writeChunkBase64(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 @@ -376,5 +506,21 @@ 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 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');
});
});
});
Loading
Loading