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.0] - 2026-07-29

- We replaced deprecated React Native APIs with modern supported APIs.
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 @@ -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>(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<String, Any> {
return mapOf(
"DocumentDirectoryPath" to filesDir,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
102 changes: 102 additions & 0 deletions example/__tests__/file-system.harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
});
});
});
56 changes: 56 additions & 0 deletions ios/Modules/NativeFsModule/NativeFsModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions ios/TurboModules/MxFileSystem/MxFileSystem.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions src/file-system/NativeMxFileSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ export interface Spec extends TurboModule {
writeJson(data: CodegenTypes.UnsafeObject, filepath: string): Promise<void>;
readJson(filepath: string): Promise<CodegenTypes.UnsafeObject | null>;
setEncryptionEnabled(enabled: boolean): void;
getFileSize(filePath: string): Promise<number>;
writeChunk(
blob: CodegenTypes.UnsafeObject,
filePath: string,
offset: number
): Promise<void>;
}

export default TurboModuleRegistry.getEnforcing<Spec>('MxFileSystem');
Expand Down
5 changes: 5 additions & 0 deletions src/file-system/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ const initFs = () => {
readJson: <T>(filepath: string) =>
NativeMxFileSystem.readJson(filepath) as Promise<T>,

//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('/'),
Expand Down
Loading