package expo.modules.filesystem import android.content.Context import android.net.Uri import android.os.Build import android.util.Base64 import android.webkit.URLUtil import androidx.annotation.RequiresApi import expo.modules.kotlin.activityresult.AppContextActivityResultLauncher import expo.modules.kotlin.devtools.await import expo.modules.kotlin.exception.Exceptions import expo.modules.kotlin.functions.Coroutine import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition import expo.modules.kotlin.services.FilePermissionService import expo.modules.kotlin.typedarray.TypedArray import expo.modules.kotlin.types.Either import okhttp3.OkHttpClient import okhttp3.Request import java.io.File import java.io.FileOutputStream import java.net.URI class FileSystemModule : Module() { private val context: Context get() = appContext.reactContext ?: throw Exceptions.AppContextLost() @RequiresApi(Build.VERSION_CODES.O) override fun definition() = ModuleDefinition { Name("FileSystem") Constant("documentDirectory") { Uri.fromFile(context.filesDir).toString() + "/" } Constant("cacheDirectory") { Uri.fromFile(context.cacheDir).toString() + "/" } Constant("bundleDirectory") { "asset://" } Property("totalDiskSpace") { File(context.filesDir.path).totalSpace } Property("availableDiskSpace") { File(context.filesDir.path).freeSpace } AsyncFunction("downloadFileAsync") Coroutine { url: URI, to: FileSystemPath, options: DownloadOptions? -> to.validatePermission(FilePermissionService.Permission.WRITE) val requestBuilder = Request.Builder().url(url.toURL()) options?.headers?.forEach { (key, value) -> requestBuilder.addHeader(key, value) } val request = requestBuilder.build() val client = OkHttpClient() val response = request.await(client) if (!response.isSuccessful) { throw UnableToDownloadException("response has status: ${response.code}") } val contentDisposition = response.headers["content-disposition"] val contentType = response.headers["content-type"] val fileName = URLUtil.guessFileName(url.toString(), contentDisposition, contentType) val destination = if (to is FileSystemDirectory) { File(to.javaFile, fileName) } else { to.javaFile } if (options?.idempotent != true && destination.exists()) { throw DestinationAlreadyExistsException() } val body = response.body ?: throw UnableToDownloadException("response body is null") body.byteStream().use { input -> FileOutputStream(destination).use { output -> input.copyTo(output) } } return@Coroutine destination.toURI() } lateinit var filePickerLauncher: AppContextActivityResultLauncher RegisterActivityContracts { filePickerLauncher = registerForActivityResult( FilePickerContract(this@FileSystemModule) ) } AsyncFunction("pickDirectoryAsync") Coroutine { initialUri: Uri? -> val result = filePickerLauncher.launch( FilePickerContractOptions(initialUri, null, PickerType.DIRECTORY) ) when (result) { is FilePickerContractResult.Success -> result.path as FileSystemDirectory is FilePickerContractResult.Cancelled -> throw PickerCancelledException() } } AsyncFunction("pickFileAsync") Coroutine { initialUri: Uri?, mimeType: String? -> val result = filePickerLauncher.launch( FilePickerContractOptions(initialUri, mimeType, PickerType.FILE) ) when (result) { is FilePickerContractResult.Success -> result.path as FileSystemFile is FilePickerContractResult.Cancelled -> throw PickerCancelledException() } } Function("info") { url: URI -> val file = File(url) val permissions = appContext .filePermission .getPathPermissions( appContext.reactContext ?: throw Exceptions.ReactContextLost(), file.path ) if (permissions.contains(FilePermissionService.Permission.READ) && file.exists()) { PathInfo(exists = file.exists(), isDirectory = file.isDirectory) } else { PathInfo(exists = false, isDirectory = null) } } Class(FileSystemFile::class) { Constructor { uri: Uri -> FileSystemFile(uri) } Function("delete") { file: FileSystemFile -> file.delete() } Function("validatePath") { file: FileSystemFile -> file.validatePath() } Function("create") { file: FileSystemFile, options: CreateOptions? -> file.create(options ?: CreateOptions()) } Function("write") { file: FileSystemFile, content: Either, options: WriteOptions? -> val append = options?.append ?: false if (content.`is`(String::class)) { content.get(String::class).let { if (options?.encoding == EncodingType.BASE64) { file.write(Base64.decode(it, Base64.DEFAULT), append) } else { file.write(it, append) } } } if (content.`is`(TypedArray::class)) { content.get(TypedArray::class).let { file.write(it, append) } } } AsyncFunction("text") { file: FileSystemFile -> file.text() } Function("textSync") { file: FileSystemFile -> file.text() } AsyncFunction("base64") { file: FileSystemFile -> file.base64() } Function("base64Sync") { file: FileSystemFile -> file.base64() } AsyncFunction("bytes") { file: FileSystemFile -> file.bytes() } Function("bytesSync") { file: FileSystemFile -> file.bytes() } Function("info") { file: FileSystemFile, options: InfoOptions? -> file.info(options) } Property("exists") { file: FileSystemFile -> file.exists } Property("modificationTime") { file: FileSystemFile -> file.modificationTime } Property("creationTime") { file: FileSystemFile -> file.creationTime } Function("copy") { file: FileSystemFile, destination: FileSystemPath -> file.copy(destination) } Function("move") { file: FileSystemFile, destination: FileSystemPath -> file.move(destination) } Function("rename") { file: FileSystemFile, newName: String -> file.rename(newName) } Property("uri") { file -> file.asString() } Property("contentUri") { file -> file.asContentUri() } Property("md5") { file -> try { file.md5 } catch (e: Exception) { null } } Property("size") { file -> try { file.size } catch (e: Exception) { null } } Property("type") { file -> file.type } Function("open") { file: FileSystemFile -> FileSystemFileHandle(file) } } Class(FileSystemFileHandle::class) { Constructor { file: FileSystemFile -> FileSystemFileHandle(file) } Function("readBytes") { fileHandle: FileSystemFileHandle, bytes: Long -> fileHandle.read(bytes) } Function("writeBytes") { fileHandle: FileSystemFileHandle, data: ByteArray -> fileHandle.write(data) } Function("close") { fileHandle: FileSystemFileHandle -> fileHandle.close() } Property("offset") { fileHandle: FileSystemFileHandle -> fileHandle.offset }.set { fileHandle: FileSystemFileHandle, offset: Long -> fileHandle.offset = offset } Property("size") { fileHandle: FileSystemFileHandle -> fileHandle.size } } Class(FileSystemDirectory::class) { Constructor { uri: Uri -> FileSystemDirectory(uri) } Function("info") { directory: FileSystemDirectory -> directory.info() } Function("delete") { directory: FileSystemDirectory -> directory.delete() } Function("create") { directory: FileSystemDirectory, options: CreateOptions? -> directory.create(options ?: CreateOptions()) } Function("createDirectory") { file: FileSystemDirectory, name: String -> return@Function file.createDirectory(name) } Function("createFile") { file: FileSystemDirectory, name: String, mimeType: String? -> return@Function file.createFile(mimeType, name) } Property("exists") { directory: FileSystemDirectory -> directory.exists } Function("validatePath") { directory: FileSystemDirectory -> directory.validatePath() } Function("copy") { directory: FileSystemDirectory, destination: FileSystemPath -> directory.copy(destination) } Function("move") { directory: FileSystemDirectory, destination: FileSystemPath -> directory.move(destination) } Function("rename") { directory: FileSystemDirectory, newName: String -> directory.rename(newName) } Property("uri") { directory -> directory.asString() } Property("size") { directory -> directory.size } // this function is internal and will be removed in the future (when returning arrays of shared objects is supported) Function("listAsRecords") { directory: FileSystemDirectory -> directory.listAsRecords() } } } }