package com.chatterui.reactnativelocaldownload import android.content.ContentResolver import android.content.ContentValues import android.content.Intent import android.net.Uri import android.os.ParcelFileDescriptor import android.provider.MediaStore import android.system.Os import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.WritableMap import com.facebook.react.bridge.WritableNativeMap import com.facebook.react.module.annotations.ReactModule import com.facebook.react.modules.core.DeviceEventManagerModule import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.net.URLConnection @ReactModule(name = ReactNativeLocalDownloadModule.NAME) class ReactNativeLocalDownloadModule( private val reactContext: ReactApplicationContext, ) : NativeReactNativeLocalDownloadSpec(reactContext) { override fun getName(): String = NAME override fun getContentFd( contentUri: String, promise: Promise, ) { try { val uri = Uri.parse(contentUri) val pfd = reactContext.contentResolver .openFileDescriptor(uri, "r") ?: run { promise.reject("FD_OPEN_FAILED", "Unable to open content URI") return } val fd = pfd.detachFd() promise.resolve("$fd") } catch (e: Exception) { promise.reject( "GET_FD_ERROR", "Failed to get detached FD: ${e.message}", e, ) } } override fun closeFd( fdOrPath: String, promise: Promise, ) { try { val fdInt = when { fdOrPath.startsWith("/proc/") -> { fdOrPath.substringAfterLast("/").toInt() } else -> { fdOrPath.toInt() } } ParcelFileDescriptor.adoptFd(fdInt).close() promise.resolve(true) } catch (e: Exception) { promise.reject( "FD_CLOSE_ERROR", "Failed to close FD: ${e.message}", e, ) } } override fun persistContentPermission( uriString: String, promise: Promise, ) { try { val uri = Uri.parse(uriString) val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION reactContext.contentResolver .takePersistableUriPermission(uri, flags) promise.resolve(true) } catch (e: SecurityException) { promise.reject( "PERSIST_PERMISSION_DENIED", "Persistable permission not granted for this URI", e, ) } catch (e: Exception) { promise.reject( "PERSIST_PERMISSION_ERROR", e.message, e, ) } } override fun localDownload( uri: String, promise: Promise, ) { try { val inputFile = File(uri) if (!inputFile.exists()) { promise.reject("FILE_NOT_FOUND", "File does not exist at path: $uri") return } val fileName = inputFile.name val mimeType = URLConnection.guessContentTypeFromName(inputFile.name) ?: "application/octet-stream" val resolver: ContentResolver = reactContext.contentResolver val uniqueName = getUniqueFileName(resolver, fileName) val contentValues = ContentValues().apply { put(MediaStore.Downloads.DISPLAY_NAME, uniqueName) put(MediaStore.Downloads.MIME_TYPE, mimeType) put(MediaStore.Downloads.IS_PENDING, 1) } val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) val itemUri = resolver.insert(collection, contentValues) if (itemUri == null) { promise.reject("SAVE_ERROR", "Failed to create destination file in MediaStore.") return } resolver.openOutputStream(itemUri)?.use { outputStream -> inputFile.inputStream().use { inputStream -> inputStream.copyTo(outputStream) } } // Mark the item as not pending so it's visible to user contentValues.clear() contentValues.put(MediaStore.Downloads.IS_PENDING, 0) resolver.update(itemUri, contentValues, null, null) promise.resolve(itemUri.toString()) } catch (e: Exception) { promise.reject("DOWNLOAD_ERROR", "Failed to save file via MediaStore: ${e.message}", e) } } override fun copyFileSAF( sourceUri: String, destPath: String, promise: Promise, ) { try { val sourceFile = getInputStreamFromUri(sourceUri) val destFile = File(destPath) // Ensure destination directory exists destFile.parentFile?.mkdirs() val totalBytes = getTotalBytes(sourceUri) val startTime = System.currentTimeMillis() sourceFile?.use { inputStream -> FileOutputStream(destFile).use { outputStream -> val buffer = ByteArray(262144) var bytesCopied: Long = 0 var bytesRead: Int var lastPercentage = -1 while (inputStream.read(buffer).also { bytesRead = it } != -1) { outputStream.write(buffer, 0, bytesRead) bytesCopied += bytesRead val elapsedSeconds = (System.currentTimeMillis() - startTime) / 1000.0 val bytesPerSec = if (elapsedSeconds > 0) bytesCopied.toDouble() / elapsedSeconds else 0.0 val percentComplete = if (totalBytes > 0) (bytesCopied.toDouble() / totalBytes.toDouble() * 100.0) else 0.0 val currentPercentage = percentComplete.toInt() if (currentPercentage != lastPercentage) { lastPercentage = currentPercentage val progress = WritableNativeMap().apply { putDouble("bytesCopied", bytesCopied.toDouble()) putDouble("totalBytes", totalBytes.toDouble()) putDouble("percentComplete", percentComplete) putDouble("bytesPerSec", bytesPerSec) } sendEvent("copyFileSAFProgress", progress) } } } } ?: run { promise.reject("SOURCE_OPEN_FAILED", "Unable to open source for reading") return } sendEvent("copyFileSAFComplete", null) promise.resolve(true) } catch (e: SecurityException) { val errorMap = WritableNativeMap().apply { putString("code", "COPY_PERMISSION_DENIED") putString("message", "Permission denied to access source URI") } sendEvent("copyFileSAFError", errorMap) promise.reject("COPY_PERMISSION_DENIED", "Permission denied to access source URI", e) } catch (e: Exception) { val errorMap = WritableNativeMap().apply { putString("code", "COPY_ERROR") putString("message", "Failed to copy file: ${e.message}") } sendEvent("copyFileSAFError", errorMap) promise.reject("COPY_ERROR", "Failed to copy file: ${e.message}", e) } } private fun getInputStreamFromUri(uri: String) = try { when { uri.startsWith("content://") -> { reactContext.contentResolver.openInputStream(Uri.parse(uri)) } uri.startsWith("file://") -> { val filePath = uri.removePrefix("file://") FileInputStream(File(filePath)) } else -> { // Try as a regular file path FileInputStream(File(uri)) } } } catch (e: Exception) { null } private fun sendEvent( eventName: String, data: WritableMap?, ) { reactContext .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) .emit(eventName, data) } private fun getTotalBytes(uri: String): Long { return try { when { uri.startsWith("content://") -> { val cursor = reactContext.contentResolver.query(Uri.parse(uri), null, null, null, null) cursor?.use { if (it.moveToFirst()) { val sizeIndex = it.getColumnIndex(android.provider.OpenableColumns.SIZE) if (sizeIndex != -1) { return it.getLong(sizeIndex) } } } 0L } uri.startsWith("file://") -> { val filePath = uri.removePrefix("file://") File(filePath).length() } else -> { // Try as regular file path File(uri).length() } } } catch (e: Exception) { 0L } } private fun getUniqueFileName( resolver: ContentResolver, baseName: String, ): String { var name = baseName val nameWithoutExtension = File(baseName).nameWithoutExtension val extension = File(baseName).extension var index = 1 val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) val projection = arrayOf(MediaStore.Downloads.DISPLAY_NAME) val selection = "${MediaStore.Downloads.DISPLAY_NAME} = ?" val selectionArgs = arrayOf(name) while (resolver.query(collection, projection, selection, selectionArgs, null)?.use { it.moveToFirst() } == true) { name = if (extension.isNotEmpty()) { "$nameWithoutExtension ($index).$extension" } else { "$nameWithoutExtension ($index)" } selectionArgs[0] = name index++ } return name } companion object { const val NAME = "ReactNativeLocalDownload" } }