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

- 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,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 @@ -230,6 +231,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 @@ -257,6 +257,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 @@ -273,5 +403,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