Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import androidx.core.app.NotificationCompat
import com.margelo.nitro.core.Promise
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
import java.io.RandomAccessFile
import java.util.concurrent.TimeUnit
Expand Down Expand Up @@ -601,15 +600,11 @@ class NitroCloudUploader(
try {
println("📤 Uploading part ${part.partNumber}/${state.totalChunks} (attempt ${retries + 1})")

// ✅ Read chunk
val chunkData = readChunk(filePath, part.offset, part.size)

// ✅ Upload chunk
// Stream the chunk from disk to avoid allocating the full part in memory.
val request = Request.Builder()
.url(part.url)
.put(chunkData.toRequestBody("application/octet-stream".toMediaType()))
.put(streamingFileChunkBody(filePath, part.offset, part.size))
.addHeader("Content-Type", "application/octet-stream")
.addHeader("Content-Length", part.size.toString())
.build()

val response = httpClient.newCall(request).execute()
Expand Down Expand Up @@ -722,21 +717,40 @@ class NitroCloudUploader(
false
}

private fun readChunk(filePath: String, offset: Long, size: Long): ByteArray {
return RandomAccessFile(File(filePath), "r").use { raf ->
if (offset + size > raf.length()) {
throw IllegalArgumentException("Invalid chunk bounds: offset=$offset, size=$size, file=${raf.length()}")
}

raf.seek(offset)
val buffer = ByteArray(size.toInt())
val bytesRead = raf.read(buffer)

if (bytesRead != size.toInt()) {
throw Exception("Read $bytesRead bytes, expected $size")
/**
* Streams a byte range from disk into an OkHttp RequestBody.
*
* Each write uses a fixed-size buffer instead of allocating the full chunk.
* The file is reopened for each write so the body can be replayed if OkHttp retries.
*/
private fun streamingFileChunkBody(filePath: String, offset: Long, size: Long): RequestBody {
val mediaType = "application/octet-stream".toMediaType()
return object : RequestBody() {
override fun contentType() = mediaType
override fun contentLength() = size
override fun writeTo(sink: okio.BufferedSink) {
RandomAccessFile(File(filePath), "r").use { raf ->
if (offset + size > raf.length()) {
throw IllegalArgumentException(
"Invalid chunk bounds: offset=$offset, size=$size, file=${raf.length()}",
)
}
raf.seek(offset)
val buffer = ByteArray(256 * 1024) // 256 KB
var remaining = size
while (remaining > 0) {
val toRead = minOf(buffer.size.toLong(), remaining).toInt()
val read = raf.read(buffer, 0, toRead)
if (read <= 0) {
throw java.io.IOException(
"Unexpected EOF at offset ${offset + (size - remaining)}",
)
}
sink.write(buffer, 0, read)
remaining -= read
}
}
}

buffer
}
}

Expand Down
50 changes: 42 additions & 8 deletions ios/NitroCloudUploader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -578,8 +578,7 @@ class UploadSession: NSObject {
let tempDir = FileManager.default.temporaryDirectory
let tempFileURL = tempDir.appendingPathComponent("chunk_\(uploadId)_\(index).tmp")

let data = try readChunkData(offset: offset, size: size)
try data.write(to: tempFileURL)
try streamChunkToFile(offset: offset, size: size, destination: tempFileURL)

guard let url = URL(string: uploadUrls[index]) else {
throw NSError(domain: "CloudUploader", code: -1,
Expand All @@ -599,12 +598,47 @@ class UploadSession: NSObject {
}
}

private func readChunkData(offset: Int64, size: Int64) throws -> Data {
let handle = try FileHandle(forReadingFrom: fileURL)
defer { try? handle.close() }

try handle.seek(toOffset: UInt64(offset))
return handle.readData(ofLength: Int(size))
/// Copies a byte range from the source file into a temporary chunk file.
///
/// Uses a fixed-size buffer instead of loading the full chunk into memory.
/// Throws on short reads to avoid uploading incomplete chunk data.
private func streamChunkToFile(offset: Int64, size: Int64, destination: URL) throws {
let bufferSize = 256 * 1024 // 256 KB
let fm = FileManager.default

let readHandle = try FileHandle(forReadingFrom: fileURL)
defer { try? readHandle.close() }
try readHandle.seek(toOffset: UInt64(offset))

// Recreate the temp file so each chunk write starts from a clean file.
if fm.fileExists(atPath: destination.path) {
try fm.removeItem(at: destination)
}
guard fm.createFile(atPath: destination.path, contents: nil, attributes: nil) else {
throw NSError(domain: "CloudUploader", code: -1,
userInfo: [NSLocalizedDescriptionKey: "Failed to create temp file at \(destination.path)"])
}

do {
let writeHandle = try FileHandle(forWritingTo: destination)
defer { try? writeHandle.close() }

var remaining = size
while remaining > 0 {
let toRead = Int(min(Int64(bufferSize), remaining))
let chunk = readHandle.readData(ofLength: toRead)
if chunk.isEmpty {
throw NSError(domain: "CloudUploader", code: -1,
userInfo: [NSLocalizedDescriptionKey: "Unexpected EOF reading chunk at offset \(offset): wanted \(size) bytes, short by \(remaining)"])
}
try writeHandle.write(contentsOf: chunk)
remaining -= Int64(chunk.count)
}
} catch {
// Remove any partial file so it cannot be uploaded later.
try? fm.removeItem(at: destination)
throw error
}
}

private func complete() {
Expand Down
13 changes: 6 additions & 7 deletions src/NitroCloudUploader.nitro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import type { HybridObject } from 'react-native-nitro-modules';
* Upload progress event for UI updates
*/
export interface UploadProgressEvent {
type: string;
type: string;
uploadId: string;
progress?: number;
progress?: number;
bytesUploaded?: number;
totalBytes?: number;
chunkIndex?: number;
Expand Down Expand Up @@ -42,26 +42,25 @@ export interface UploadState {
* - Background uploads with optional notifications
* - Event system for UI feedback
* - Parallel chunk uploads
*
*
* Note: Chunk size is calculated automatically: ceil(fileSize / numberOfURLs)
*/
export interface NitroCloudUploader
extends HybridObject<{ ios: 'swift'; android: 'kotlin' }> {

/**
* Start uploading a file in chunks
* - Supports pause/resume/cancel
* - Automatically handles network drops
* - Works in background with optional notifications
* - Uploads multiple chunks in parallel (default: 3)
*
*
* @param uploadId - Unique identifier
* @param filePath - Local file path (decoded, no file://)
* @param uploadUrls - Presigned URLs for each chunk
* @param maxParallel - Number of chunks to upload simultaneously (default: 3)
* @param showNotification - Show progress notification (default: true)
* @returns Promise that resolves when upload completes
*
*
* Note: Chunk size is calculated automatically: ceil(fileSize / uploadUrls.length)
*/
startUpload(
Expand Down Expand Up @@ -112,4 +111,4 @@ export interface NitroCloudUploader
* Remove event listener
*/
removeListener(eventType: string): void;
}
}
Loading