first commit

This commit is contained in:
2026-03-10 16:18:05 +00:00
commit 11f9c069b5
31635 changed files with 3187747 additions and 0 deletions

25
node_modules/expo-file-system/android/build.gradle generated vendored Normal file
View File

@@ -0,0 +1,25 @@
plugins {
id 'com.android.library'
id 'expo-module-gradle-plugin'
}
group = 'host.exp.exponent'
version = '55.0.10'
android {
namespace "expo.modules.filesystem"
defaultConfig {
versionCode 30
versionName "55.0.10"
}
}
dependencies {
api 'commons-codec:commons-codec:1.10'
api 'commons-io:commons-io:1.4'
api 'com.squareup.okhttp3:okhttp:4.9.2'
api 'com.squareup.okhttp3:okhttp-urlconnection:4.9.2'
api 'com.squareup.okio:okio:2.9.0'
api "androidx.legacy:legacy-support-v4:1.0.0"
api "androidx.documentfile:documentfile:1.1.0"
}

View File

@@ -0,0 +1,17 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<application>
<provider tools:replace="android:authorities" android:name=".FileSystemFileProvider" android:authorities="${applicationId}.FileSystemFileProvider" android:exported="false" android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_system_provider_paths" />
</provider>
</application>
<queries>
<!-- Query open documents -->
<intent>
<action android:name="android.intent.action.OPEN_DOCUMENT_TREE" />
</intent>
</queries>
</manifest>

View File

@@ -0,0 +1,71 @@
package expo.modules.filesystem
import android.annotation.SuppressLint
import android.app.Activity
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.DocumentsContract
import expo.modules.kotlin.activityresult.AppContextActivityResultContract
import expo.modules.kotlin.providers.AppContextProvider
import java.io.Serializable
@SuppressLint("WrongConstant")
internal class FilePickerContract(private val appContextProvider: AppContextProvider) : AppContextActivityResultContract<FilePickerContractOptions, FilePickerContractResult> {
private val contentResolver: ContentResolver
get() = requireNotNull(appContextProvider.appContext.reactContext) {
"React Application Context is null"
}.contentResolver
override fun createIntent(context: Context, input: FilePickerContractOptions): Intent =
if (input.pickerType == PickerType.FILE) {
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
// if no type is set no intent handler is found just android things
type = input.mimeType ?: "*/*"
}
} else {
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
}.also { intent ->
// intent.addCategory(Intent.CATEGORY_OPENABLE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
input.initialUri.let { intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, it) }
}
}
override fun parseResult(input: FilePickerContractOptions, resultCode: Int, intent: Intent?): FilePickerContractResult =
if (resultCode == Activity.RESULT_CANCELED || intent == null) {
FilePickerContractResult.Cancelled
} else {
val uri = intent.data
val takeFlags = (intent.flags.and((Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)))
uri?.let {
contentResolver.takePersistableUriPermission(it, takeFlags)
}
when (input.pickerType) {
PickerType.DIRECTORY -> FilePickerContractResult.Success(
FileSystemDirectory(
uri
?: Uri.EMPTY
)
)
PickerType.FILE -> {
FilePickerContractResult.Success(FileSystemFile(uri ?: Uri.EMPTY))
}
}
}
}
internal data class FilePickerContractOptions(val initialUri: Uri?, val mimeType: String? = null, val pickerType: PickerType = PickerType.FILE) : Serializable
internal enum class PickerType {
FILE,
DIRECTORY
}
internal sealed class FilePickerContractResult {
class Success(val path: FileSystemPath) : FilePickerContractResult()
object Cancelled : FilePickerContractResult()
}

View File

@@ -0,0 +1,118 @@
package expo.modules.filesystem
import android.net.Uri
import expo.modules.kotlin.services.FilePermissionService
class FileSystemDirectory(uri: Uri) : FileSystemPath(uri) {
fun validatePath() {
// Kept empty for now, but can be used to validate if the path is a valid directory path.
}
override fun validateType() {
if (file.exists() && !file.isDirectory()) {
throw InvalidTypeFolderException()
}
}
val exists: Boolean get() {
return if (checkPermission(FilePermissionService.Permission.READ)) {
file.isDirectory()
} else {
false
}
}
val size: Long get() {
validatePermission(FilePermissionService.Permission.READ)
validateType()
return file.walkTopDown().filter { it.isFile() }.map { it.length() }.sum()
}
fun info(): DirectoryInfo {
validateType()
validatePermission(FilePermissionService.Permission.READ)
if (!file.exists()) {
val directoryInfo = DirectoryInfo(
exists = false,
uri = slashifyFilePath(file.uri.toString())
)
return directoryInfo
}
val directoryInfo = DirectoryInfo(
exists = true,
uri = slashifyFilePath(file.uri.toString()),
files = file.listFilesAsUnified().mapNotNull { i -> i.fileName },
modificationTime = modificationTime,
creationTime = creationTime,
size = size
)
return directoryInfo
}
fun create(options: CreateOptions = CreateOptions()) {
validateType()
validatePermission(FilePermissionService.Permission.WRITE)
if (!needsCreation(options)) {
return
}
if (uri.isContentUri) {
throw UnableToCreateException("create function does not work with SAF Uris, use `createDirectory` and `createFile` instead")
}
validateCanCreate(options)
if (options.overwrite && file.exists()) {
file.delete()
}
val created = if (options.intermediates) {
javaFile.mkdirs()
} else {
javaFile.mkdir()
}
if (!created) {
throw UnableToCreateException("directory already exists or could not be created")
}
}
fun createFile(mimeType: String?, fileName: String): FileSystemFile {
validateType()
validatePermission(FilePermissionService.Permission.WRITE)
val newFile = file.createFile(mimeType ?: "text/plain", fileName)
?: throw UnableToCreateException("file could not be created")
return FileSystemFile(newFile.uri)
}
fun createDirectory(fileName: String): FileSystemDirectory {
validateType()
validatePermission(FilePermissionService.Permission.WRITE)
val newDirectory = file.createDirectory(fileName)
?: throw UnableToCreateException("directory could not be created")
return FileSystemDirectory(newDirectory.uri)
}
// this function is internal and will be removed in the future (when returning arrays of shared objects is supported)
fun listAsRecords(): List<Map<String, Any>> {
validateType()
validatePermission(FilePermissionService.Permission.READ)
return file.listFilesAsUnified().map {
val uriString = it.uri.toString()
val isDir = it.isDirectory()
mapOf(
"isDirectory" to isDir,
"uri" to if (isDir) {
if (uriString.endsWith("/")) uriString else "$uriString/"
} else {
uriString
}
)
}
}
fun asString(): String {
val uriString = file.uri.toString()
return if (uriString.endsWith("/")) uriString else "$uriString/"
}
fun needsCreation(options: CreateOptions): Boolean {
return !file.exists() || !options.idempotent
}
}

View File

@@ -0,0 +1,58 @@
package expo.modules.filesystem
import expo.modules.kotlin.exception.CodedException
import expo.modules.kotlin.services.FilePermissionService
internal class CopyOrMoveDirectoryToFileException :
CodedException("Unable to copy or move a folder to a file")
internal class InvalidTypeFolderException :
CodedException("A file with the same name already exists in the folder location")
internal class InvalidTypeFileException :
CodedException("A folder with the same name already exists in the file location")
internal class DestinationDoesNotExistException :
CodedException("The destination path does not exist")
internal class UnableToDownloadException(reason: String) :
CodedException(
"Unable to download a file: $reason"
)
internal class UnableToDeleteException(reason: String) :
CodedException(
"Unable to delete file or directory: $reason"
)
internal class UnableToCreateException(reason: String) :
CodedException(
"Unable to create file or directory: $reason"
)
internal class InvalidPermissionException(permission: FilePermissionService.Permission) :
CodedException(
"Missing '${permission.name}' permission for accessing the file."
)
internal class UnableToReadHandleException(reason: String) :
CodedException(
"Unable to read from a file handle: '$reason'"
)
internal class UnableToWriteHandleException(reason: String) :
CodedException(
"Unable to write to a file handle: '$reason'"
)
internal class MissingAppContextException :
CodedException(
"The app context is missing."
)
internal class PickerCancelledException :
CodedException("The file picker was cancelled by the user")
internal class DestinationAlreadyExistsException :
CodedException(
"Destination already exists"
)

View File

@@ -0,0 +1,177 @@
package expo.modules.filesystem
import android.net.Uri
import android.util.Base64
import expo.modules.kotlin.services.FilePermissionService
import expo.modules.kotlin.typedarray.TypedArray
import java.io.FileOutputStream
import java.security.MessageDigest
class FileSystemFile(uri: Uri) : FileSystemPath(uri) {
// Kept empty for now, but can be used to validate if the uri is a valid file uri. // TODO: Move to the constructor once also moved on iOS
fun validatePath() {
}
// This makes sure that if a file already exists at a location, it is the correct type so that all available operations perform as expected.
// After calling this function, we can use the `isDirectory` and `isFile` functions safely as they will match the shared class used.
override fun validateType() {
validatePermission(FilePermissionService.Permission.READ)
if (file.exists() && file.isDirectory()) {
throw InvalidTypeFileException()
}
}
val exists: Boolean get() {
return if (checkPermission(FilePermissionService.Permission.READ)) {
file.isFile()
} else {
false
}
}
fun create(options: CreateOptions = CreateOptions()) {
validateType()
validatePermission(FilePermissionService.Permission.WRITE)
validateCanCreate(options)
if (uri.isContentUri) {
throw UnableToCreateException("create function does not work with SAF Uris, use `createDirectory` and `createFile` instead")
}
if (options.overwrite && exists) {
javaFile.delete()
}
if (options.intermediates) {
javaFile.parentFile?.mkdirs()
}
val created = javaFile.createNewFile()
if (!created) {
throw UnableToCreateException("file already exists or could not be created")
}
}
fun write(content: String, append: Boolean = false) {
validateType()
validatePermission(FilePermissionService.Permission.WRITE)
if (!exists) {
create()
}
file.outputStream(append).use { outputStream ->
outputStream.write(content.toByteArray())
}
}
fun write(content: TypedArray, append: Boolean = false) {
validateType()
validatePermission(FilePermissionService.Permission.WRITE)
if (!exists) {
create()
}
if (uri.isContentUri) {
file.outputStream(append).use { outputStream ->
val array = ByteArray(content.length)
content.toDirectBuffer().get(array)
outputStream.write(array)
}
} else {
FileOutputStream(javaFile, append).use {
it.channel.write(content.toDirectBuffer())
}
}
}
fun write(content: ByteArray, append: Boolean = false) {
validateType()
validatePermission(FilePermissionService.Permission.WRITE)
if (!exists) {
create()
}
if (uri.isContentUri) {
file.outputStream(append).use { outputStream ->
outputStream.write(content)
}
} else {
FileOutputStream(javaFile, append).use {
it.write(content)
}
}
}
fun asString(): String {
val uriString = file.uri.toString()
return if (uriString.endsWith("/")) uriString.dropLast(1) else uriString
}
fun text(): String {
validateType()
validatePermission(FilePermissionService.Permission.READ)
return file.inputStream().use { inputStream ->
inputStream.bufferedReader().use { it.readText() }
}
}
fun base64(): String {
validateType()
validatePermission(FilePermissionService.Permission.READ)
file.inputStream().use {
return Base64.encodeToString(it.readBytes(), Base64.NO_WRAP)
}
}
fun bytes(): ByteArray {
validateType()
validatePermission(FilePermissionService.Permission.READ)
file.inputStream().use {
return it.readBytes()
}
}
fun asContentUri(): Uri {
validateType()
validatePermission(FilePermissionService.Permission.READ)
return file.getContentUri(appContext ?: throw MissingAppContextException())
}
@OptIn(ExperimentalStdlibApi::class)
val md5: String get() {
validatePermission(FilePermissionService.Permission.READ)
val md = MessageDigest.getInstance("MD5")
file.inputStream().use {
val digest = md.digest(it.readBytes())
return digest.toHexString()
}
}
val size: Long? get() {
return if (file.exists()) {
file.length()
} else {
null
}
}
val type: String? get() {
return file.type
}
fun info(options: InfoOptions?): FileInfo {
validateType()
validatePermission(FilePermissionService.Permission.READ)
if (!file.exists()) {
val fileInfo = FileInfo(
exists = false,
uri = slashifyFilePath(file.uri.toString())
)
return fileInfo
}
val fileInfo = FileInfo(
exists = true,
uri = slashifyFilePath(file.uri.toString()),
size = size,
modificationTime = modificationTime,
creationTime = creationTime
)
if (options != null && options.md5 == true) {
fileInfo.md5 = md5
}
return fileInfo
}
}

View File

@@ -0,0 +1,85 @@
package expo.modules.filesystem
import expo.modules.kotlin.sharedobjects.SharedRef
import java.io.RandomAccessFile
import java.nio.ByteBuffer
import java.nio.channels.FileChannel
import kotlin.math.min
class FileSystemFileHandle(file: FileSystemFile) : SharedRef<FileChannel>(RandomAccessFile(file.javaFile, "rw").channel), AutoCloseable {
private val fileChannel: FileChannel = ref
private fun ensureIsOpen() {
if (!fileChannel.isOpen) {
throw UnableToReadHandleException("file handle is closed")
}
}
override fun sharedObjectDidRelease() {
close()
}
override fun close() {
fileChannel.close()
}
fun read(length: Long): ByteArray {
ensureIsOpen()
try {
val currentPosition = fileChannel.position()
val totalSize = fileChannel.size()
val available = totalSize - currentPosition
val readAmount = min(length, available).coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
if (readAmount <= 0) {
return ByteArray(0)
}
val buffer = ByteBuffer.allocate(readAmount)
var bytesRead = 0
while (bytesRead < readAmount) {
val result = fileChannel.read(buffer)
if (result == -1) break
bytesRead += result
}
return buffer.array()
} catch (e: Exception) {
throw UnableToReadHandleException(e.message ?: "unknown error")
}
}
fun write(data: ByteArray) {
ensureIsOpen()
try {
val buffer = ByteBuffer.wrap(data)
while (buffer.hasRemaining()) {
fileChannel.write(buffer)
}
} catch (e: Exception) {
throw UnableToWriteHandleException(e.message ?: "unknown error")
}
}
var offset: Long?
get() {
return try {
fileChannel.position()
} catch (e: Exception) {
null
}
}
set(value) {
if (value == null) return
fileChannel.position(value)
}
val size: Long?
get() {
return try {
fileChannel.size()
} catch (e: Exception) {
null
}
}
}

View File

@@ -0,0 +1,5 @@
package expo.modules.filesystem
import androidx.core.content.FileProvider
class FileSystemFileProvider : FileProvider()

View File

@@ -0,0 +1,335 @@
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<FilePickerContractOptions, FilePickerContractResult>
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<String, TypedArray>, 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()
}
}
}
}

View File

@@ -0,0 +1,62 @@
package expo.modules.filesystem
import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record
import expo.modules.kotlin.types.Enumerable
data class InfoOptions(
@Field
val md5: Boolean?
) : Record
data class CreateOptions(
@Field
val intermediates: Boolean = false,
@Field
val overwrite: Boolean = false,
@Field
val idempotent: Boolean = false
) : Record
enum class EncodingType(val value: String) : Enumerable {
UTF8("utf8"),
BASE64("base64")
}
data class WriteOptions(
@Field
val encoding: EncodingType = EncodingType.UTF8,
@Field
val append: Boolean = false
) : Record
data class DownloadOptions(
@Field
val headers: Map<String, String> = emptyMap(),
@Field
val idempotent: Boolean = false
) : Record
data class FileInfo(
@Field var exists: Boolean,
@Field var uri: String?,
@Field var md5: String? = null,
@Field var size: Long? = null,
@Field var modificationTime: Long? = null,
@Field var creationTime: Long? = null
) : Record
data class PathInfo(
@Field var exists: Boolean,
@Field var isDirectory: Boolean?
) : Record
data class DirectoryInfo(
@Field var exists: Boolean,
@Field var uri: String?,
@Field var files: List<String>? = null,
@Field var md5: String? = null,
@Field var size: Long? = null,
@Field var modificationTime: Long? = null,
@Field var creationTime: Long? = null
) : Record

View File

@@ -0,0 +1,188 @@
package expo.modules.filesystem
import android.net.Uri
import android.os.Build
import androidx.core.net.toUri
import expo.modules.filesystem.unifiedfile.AssetFile
import expo.modules.filesystem.unifiedfile.JavaFile
import expo.modules.filesystem.unifiedfile.SAFDocumentFile
import expo.modules.filesystem.unifiedfile.UnifiedFileInterface
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.services.FilePermissionService
import expo.modules.kotlin.sharedobjects.SharedObject
import java.io.File
import java.util.EnumSet
import java.util.regex.Pattern
import kotlin.io.path.moveTo
val Uri.isContentUri
get(): Boolean {
return scheme == "content"
}
val Uri.isAssetUri
get(): Boolean {
return scheme == "asset"
}
fun slashifyFilePath(path: String?): String? {
return if (path == null) {
null
} else if (path.startsWith("file:///")) {
path
} else {
// Ensure leading schema with a triple slash
Pattern.compile("^file:/*").matcher(path).replaceAll("file:///")
}
}
abstract class FileSystemPath(var uri: Uri) : SharedObject() {
private var fileAdapter: UnifiedFileInterface? = null
val file: UnifiedFileInterface
get() {
val currentAdapter = fileAdapter
if (currentAdapter?.uri == uri) {
return currentAdapter
}
val newAdapter = if (uri.isContentUri) {
SAFDocumentFile(appContext?.reactContext ?: throw Exception("No context"), uri)
} else if (uri.isAssetUri) {
AssetFile(appContext?.reactContext ?: throw Exception("No context"), uri)
} else {
JavaFile(uri)
}
fileAdapter = newAdapter
return newAdapter
}
val javaFile: File
get() =
if (uri.isContentUri) {
throw Exception("This method cannot be used with content URIs: $uri")
} else {
(file as File)
}
fun delete() {
if (!file.exists()) {
throw UnableToDeleteException("uri '${file.uri}' does not exist")
}
if (file.isDirectory()) {
if (!file.deleteRecursively()) {
throw UnableToDeleteException("failed to delete '${file.uri}'")
}
} else {
if (!file.delete()) {
throw UnableToDeleteException("failed to delete '${file.uri}'")
}
}
}
abstract fun validateType()
fun getMoveOrCopyPath(destination: FileSystemPath): File {
if (destination is FileSystemDirectory) {
if (this is FileSystemFile) {
if (!destination.exists) {
throw DestinationDoesNotExistException()
}
return File(destination.javaFile, javaFile.name)
}
// this if FileSystemDirectory
// we match unix behavior https://askubuntu.com/a/763915
if (destination.exists) {
return File(destination.javaFile, javaFile.name)
}
if (destination.javaFile.parentFile?.exists() != true) {
throw DestinationDoesNotExistException()
}
return destination.javaFile
}
// destination is FileSystemFile
if (this !is FileSystemFile) {
throw CopyOrMoveDirectoryToFileException()
}
if (destination.javaFile.parentFile?.exists() != true) {
throw DestinationDoesNotExistException()
}
return destination.javaFile
}
fun validatePermission(permission: FilePermissionService.Permission) {
if (!checkPermission(permission)) {
throw InvalidPermissionException(permission)
}
}
fun checkPermission(permission: FilePermissionService.Permission): Boolean {
if (uri.isContentUri) {
// TODO: Consider adding a check for content URIs (not in legacy FS)
return true
}
if (uri.isAssetUri) {
// TODO: Consider adding a check for asset URIs this returns asset files of Expo Go (such as root-cert), but these are already freely available on apk mirrors ect.
return true
}
val permissions = appContext?.filePermission?.getPathPermissions(
appContext?.reactContext ?: throw Exceptions.ReactContextLost(), javaFile.path
) ?: EnumSet.noneOf(FilePermissionService.Permission::class.java)
return permissions.contains(permission)
}
fun validateCanCreate(options: CreateOptions) {
if (!options.overwrite && file.exists()) {
throw UnableToCreateException("it already exists")
}
}
fun copy(to: FileSystemPath) {
validateType()
to.validateType()
validatePermission(FilePermissionService.Permission.READ)
to.validatePermission(FilePermissionService.Permission.WRITE)
javaFile.copyRecursively(getMoveOrCopyPath(to))
}
fun move(to: FileSystemPath) {
validateType()
to.validateType()
validatePermission(FilePermissionService.Permission.WRITE)
to.validatePermission(FilePermissionService.Permission.WRITE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val destination = getMoveOrCopyPath(to)
javaFile.toPath().moveTo(destination.toPath())
uri = destination.toUri()
} else {
javaFile.copyTo(getMoveOrCopyPath(to))
javaFile.delete()
uri = getMoveOrCopyPath(to).toUri()
}
}
fun rename(newName: String) {
validateType()
validatePermission(FilePermissionService.Permission.WRITE)
val newFile = File(javaFile.parent, newName)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
javaFile.toPath().moveTo(newFile.toPath())
uri = newFile.toUri()
} else {
javaFile.copyTo(newFile)
javaFile.delete()
uri = newFile.toUri()
}
}
val modificationTime: Long?
get() {
validateType()
return file.lastModified()
}
val creationTime: Long?
get() {
return file.creationTime
}
}

View File

@@ -0,0 +1,52 @@
package expo.modules.filesystem.legacy
import okhttp3.RequestBody
import okio.Buffer
import okio.BufferedSink
import okio.ForwardingSink
import okio.Sink
import okio.buffer
import java.io.IOException
@FunctionalInterface
fun interface RequestBodyDecorator {
fun decorate(requestBody: RequestBody): RequestBody
}
@FunctionalInterface
interface CountingRequestListener {
fun onProgress(bytesWritten: Long, contentLength: Long)
}
private class CountingSink(
sink: Sink,
private val requestBody: RequestBody,
private val progressListener: CountingRequestListener
) : ForwardingSink(sink) {
private var bytesWritten = 0L
override fun write(source: Buffer, byteCount: Long) {
super.write(source, byteCount)
bytesWritten += byteCount
progressListener.onProgress(bytesWritten, requestBody.contentLength())
}
}
class CountingRequestBody(
private val requestBody: RequestBody,
private val progressListener: CountingRequestListener
) : RequestBody() {
override fun contentType() = requestBody.contentType()
@Throws(IOException::class)
override fun contentLength() = requestBody.contentLength()
override fun writeTo(sink: BufferedSink) {
val countingSink = CountingSink(sink, this, progressListener)
val bufferedSink = countingSink.buffer()
requestBody.writeTo(bufferedSink)
bufferedSink.flush()
}
}

View File

@@ -0,0 +1,48 @@
package expo.modules.filesystem.legacy
import android.net.Uri
import expo.modules.kotlin.exception.CodedException
internal class FileSystemOkHttpNullException :
CodedException("okHttpClient is null")
internal class FileSystemCannotReadDirectoryException(uri: Uri?) :
CodedException("Uri '$uri' doesn't exist or isn't a directory")
internal class FileSystemCannotCreateDirectoryException(uri: Uri?) :
CodedException(
uri?.let {
"Directory '$it' could not be created or already exists"
} ?: "Unknown error"
)
internal class FileSystemUnreadableDirectoryException(uri: String) :
CodedException("No readable files with the uri '$uri'. Please use other uri")
internal class FileSystemCannotCreateFileException(uri: Uri?) :
CodedException(
uri?.let {
"Provided uri '$it' is not pointing to a directory"
} ?: "Unknown error"
)
internal class FileSystemFileNotFoundException(uri: Uri?) :
CodedException("File '$uri' could not be deleted because it could not be found")
internal class FileSystemPendingPermissionsRequestException :
CodedException("You have an unfinished permission request")
internal class FileSystemCannotMoveFileException(fromUri: Uri, toUri: Uri) :
CodedException("File '$fromUri' could not be moved to '$toUri'")
internal class FileSystemUnsupportedSchemeException :
CodedException("Can't read Storage Access Framework directory, use StorageAccessFramework.readDirectoryAsync() instead")
internal class FileSystemCannotFindTaskException :
CodedException("Cannot find task")
internal class FileSystemCopyFailedException(uri: Uri?) :
CodedException("File '$uri' could not be copied because it could not be found")
internal class CookieHandlerNotFoundException :
CodedException("Failed to find CookieHandler")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,92 @@
package expo.modules.filesystem.legacy
import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record
import expo.modules.kotlin.types.Enumerable
data class InfoOptionsLegacy(
@Field
val md5: Boolean?
) : Record
data class DeletingOptions(
@Field
val idempotent: Boolean = false
) : Record
data class ReadingOptions(
@Field
val encoding: EncodingType = EncodingType.UTF8,
@Field
val position: Int?,
@Field
val length: Int?
) : Record
enum class EncodingType(val value: String) : Enumerable {
UTF8("utf8"),
BASE64("base64")
}
enum class SessionType(val value: Int) : Enumerable {
BACKGROUND(0),
FOREGROUND(1)
}
enum class FileSystemUploadType(val value: Int) : Enumerable {
BINARY_CONTENT(0),
MULTIPART(1)
}
data class MakeDirectoryOptions(
@Field
val intermediates: Boolean = false
) : Record
data class RelocatingOptions(
@Field
val from: String,
@Field
val to: String
) : Record
data class DownloadOptionsLegacy(
@Field
val md5: Boolean = false,
@Field
val cache: Boolean?,
@Field
val headers: Map<String, String>?,
@Field
val sessionType: SessionType = SessionType.BACKGROUND
) : Record
data class WritingOptions(
@Field
val encoding: EncodingType = EncodingType.UTF8,
@Field
val append: Boolean = false
) : Record
data class FileSystemUploadOptions(
@Field
val headers: Map<String, String>?,
@Field
val httpMethod: HttpMethod = HttpMethod.POST,
@Field
val sessionType: SessionType = SessionType.BACKGROUND,
@Field
val uploadType: FileSystemUploadType,
@Field
val fieldName: String?,
@Field
val mimeType: String?,
@Field
val parameters: Map<String, String>?
) : Record
enum class HttpMethod(val value: String) : Enumerable {
POST("POST"),
PUT("PUT"),
PATCH("PATCH")
}

View File

@@ -0,0 +1,143 @@
package expo.modules.filesystem.unifiedfile
import android.content.Context
import android.net.Uri
import android.webkit.MimeTypeMap
import androidx.core.net.toUri
import expo.modules.kotlin.AppContext
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.io.OutputStream
class AssetFile(private val context: Context, override val uri: Uri) : UnifiedFileInterface {
val path: String = uri.path?.trimStart('/') ?: throw IllegalArgumentException("Invalid asset URI: $uri")
override fun exists(): Boolean = isDirectory() || isFile()
override fun isDirectory(): Boolean {
return context.assets.list(path)?.isNotEmpty() == true
}
override fun isFile(): Boolean {
return runCatching {
context.assets.open(path).use { true }
}.getOrElse {
false
}
}
// Cache the content URI to avoid redundant file writes assets are read-only
var contentUri: Uri? = null
override fun getContentUri(appContext: AppContext): Uri {
inputStream().use { inputStream ->
val outputFile = File(context.cacheDir, "expo_shared_assets/$fileName")
outputFile.parentFile?.mkdirs() // Create directories if needed
FileOutputStream(outputFile).use { outputStream ->
inputStream.copyTo(outputStream)
}
val newContentUri = JavaFile(outputFile.toUri()).getContentUri(appContext)
contentUri = newContentUri
return newContentUri
}
}
override val parentFile: UnifiedFileInterface?
get() {
val currentPath = uri.path.orEmpty()
if (currentPath.isEmpty()) {
return null
}
val parentPath = currentPath.substringBeforeLast('/')
val parentUri = "asset:///$parentPath".toUri()
return AssetFile(context, parentUri)
}
override fun createFile(mimeType: String, displayName: String): UnifiedFileInterface? {
throw UnsupportedOperationException("Asset files are not writable and cannot be created")
}
override fun createDirectory(displayName: String): UnifiedFileInterface? {
throw UnsupportedOperationException("Asset directories are not writable and cannot be created")
}
override fun delete(): Boolean = throw UnsupportedOperationException("Asset files are not writable and cannot be deleted")
override fun deleteRecursively(): Boolean = throw UnsupportedOperationException("Asset files are not writable and cannot be deleted")
override fun listFilesAsUnified(): List<UnifiedFileInterface> {
val list = context.assets.list(path)
return list?.map { name ->
val childPath = if (path.isEmpty()) name else "$path/$name"
AssetFile(context, "asset:///$childPath".toUri()) as UnifiedFileInterface
} ?: emptyList()
}
override val type: String?
get() {
val extension = MimeTypeMap.getFileExtensionFromUrl(uri.toString())
return if (extension.isNotEmpty()) {
MimeTypeMap
.getSingleton()
.getMimeTypeFromExtension(extension.lowercase())
} else {
null
}
}
override fun lastModified(): Long? {
return null
}
override val fileName: String?
get() = uri.lastPathSegment
override val creationTime: Long? get() {
return null
}
override fun outputStream(append: Boolean): OutputStream {
throw UnsupportedOperationException("Asset files are not writable")
}
override fun inputStream(): InputStream {
return context.assets.open(path)
}
override fun length(): Long {
runCatching {
context.assets.openFd(path).use { assetFileDescriptor ->
val length = assetFileDescriptor.length
if (length > 0) {
return length
}
}
}
runCatching {
var size: Long = 0
context.assets.open(path).use { inputStream ->
val buffer = ByteArray(8192)
var read: Int
while (inputStream.read(buffer).also { read = it } != -1) {
size += read
}
}
return size
}
return 0
}
override fun walkTopDown(): Sequence<AssetFile> = sequence {
yield(this@AssetFile)
if (isDirectory()) {
val assets = context.assets.list(path)
assets?.forEach { assetName ->
val childPath = if (path.isEmpty()) assetName else "$path/$assetName"
val childFile = AssetFile(context, "asset:///$childPath".toUri())
yieldAll(childFile.walkTopDown())
}
}
}
}

View File

@@ -0,0 +1,82 @@
package expo.modules.filesystem.unifiedfile
import android.net.Uri
import android.os.Build
import android.webkit.MimeTypeMap
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import expo.modules.kotlin.AppContext
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.InputStream
import java.io.OutputStream
import java.net.URI
import java.nio.file.attribute.BasicFileAttributes
import kotlin.io.path.Path
import kotlin.io.path.readAttributes
import kotlin.time.Duration.Companion.milliseconds
class JavaFile(override val uri: Uri) : UnifiedFileInterface, File(URI.create(uri.toString())) {
override val parentFile: UnifiedFileInterface?
get() = super<File>.parentFile?.toUri()?.let { JavaFile(it) }
override fun createFile(mimeType: String, displayName: String): UnifiedFileInterface? {
val childFile = File(super<File>.parentFile, displayName)
childFile.createNewFile()
return JavaFile(childFile.toUri())
}
override fun createDirectory(displayName: String): UnifiedFileInterface? {
val childFile = File(super<File>.parentFile, displayName)
childFile.mkdir()
return JavaFile(childFile.toUri())
}
override fun deleteRecursively(): Boolean {
if (isDirectory) {
listFiles()?.forEach { it.deleteRecursively() }
}
return super<File>.delete()
}
override fun getContentUri(appContext: AppContext): Uri {
return FileProvider.getUriForFile(
appContext.throwingActivity.application,
"${appContext.throwingActivity.application.packageName}.FileSystemFileProvider",
this
)
}
override fun listFilesAsUnified(): List<UnifiedFileInterface> =
super<File>.listFiles()?.map { JavaFile(it.toUri()) } ?: emptyList()
override val type: String? get() {
return MimeTypeMap.getFileExtensionFromUrl(path)
?.run { MimeTypeMap.getSingleton().getMimeTypeFromExtension(lowercase()) }
}
override val fileName: String?
get() = super<File>.name
override val creationTime: Long? get() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val attributes = Path(path).readAttributes<BasicFileAttributes>()
return attributes.creationTime().toMillis().milliseconds.inWholeMilliseconds
} else {
return null
}
}
override fun outputStream(append: Boolean): OutputStream {
return FileOutputStream(this, append)
}
override fun inputStream(): InputStream {
return FileInputStream(this)
}
override fun walkTopDown(): Sequence<JavaFile> {
return walk(direction = FileWalkDirection.TOP_DOWN).map { JavaFile(it.toUri()) }
}
}

View File

@@ -0,0 +1,105 @@
package expo.modules.filesystem.unifiedfile
import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import expo.modules.kotlin.AppContext
import java.io.InputStream
import java.io.OutputStream
class SAFDocumentFile(private val context: Context, override val uri: Uri) : UnifiedFileInterface {
private val documentFile: DocumentFile?
get() {
// Relying on singleUri.isDirectory did not work, and there's no explicit method for this, so we check path
val pathSegment = uri.pathSegments.getOrNull(0) ?: "tree"
if (pathSegment == "document") {
// If the path starts with "document", we treat it as a raw file URI
return DocumentFile.fromSingleUri(context, uri)
} else {
// Otherwise, we treat it as a tree URI
return DocumentFile.fromTreeUri(context, uri)
}
}
override fun exists(): Boolean = documentFile?.exists() == true
override fun isDirectory(): Boolean {
return documentFile?.isDirectory == true
}
override fun isFile(): Boolean {
return documentFile?.isFile == true
}
override val parentFile: UnifiedFileInterface?
get() = documentFile?.parentFile?.uri?.let { SAFDocumentFile(context, it) }
override fun createFile(mimeType: String, displayName: String): UnifiedFileInterface? {
val documentFile = documentFile?.createFile(mimeType, displayName)
return documentFile?.uri?.let { SAFDocumentFile(context, it) }
}
override fun createDirectory(displayName: String): UnifiedFileInterface? {
val documentFile = documentFile?.createDirectory(displayName)
return documentFile?.uri?.let { SAFDocumentFile(context, it) }
}
override fun delete(): Boolean = documentFile?.delete() == true
override fun deleteRecursively(): Boolean = documentFile?.deleteRecursively() == true
override fun listFilesAsUnified(): List<UnifiedFileInterface> =
documentFile?.listFiles()?.map { SAFDocumentFile(context, it.uri) } ?: emptyList()
override val type: String?
get() = documentFile?.type
override fun lastModified(): Long? {
return documentFile?.lastModified()
}
override val fileName: String?
get() = documentFile?.name
override fun getContentUri(appContext: AppContext): Uri {
return uri
}
override val creationTime: Long? get() {
// It seems there's no way to get this
return null
}
override fun outputStream(append: Boolean): OutputStream {
val mode = if (append) "wa" else "w"
return context.contentResolver.openOutputStream(uri, mode)
?: throw IllegalStateException("Unable to open output stream for URI: $uri")
}
override fun inputStream(): InputStream {
return context.contentResolver.openInputStream(uri)
?: throw IllegalStateException("Unable to open input stream for URI: $uri")
}
override fun length(): Long {
return documentFile?.length() ?: 0
}
override fun walkTopDown(): Sequence<SAFDocumentFile> {
return sequence {
yield(this@SAFDocumentFile)
if (isDirectory()) {
documentFile?.listFiles()?.forEach { child ->
yieldAll(SAFDocumentFile(context, child.uri).walkTopDown())
}
}
}
}
}
fun DocumentFile.deleteRecursively(): Boolean {
if (isDirectory) {
listFiles().forEach { it.deleteRecursively() }
}
return delete()
}

View File

@@ -0,0 +1,26 @@
package expo.modules.filesystem.unifiedfile
import android.net.Uri
import expo.modules.kotlin.AppContext
interface UnifiedFileInterface {
fun exists(): Boolean
fun isDirectory(): Boolean
fun isFile(): Boolean
val parentFile: UnifiedFileInterface?
fun createFile(mimeType: String, displayName: String): UnifiedFileInterface?
fun createDirectory(displayName: String): UnifiedFileInterface?
fun delete(): Boolean
fun deleteRecursively(): Boolean
fun listFilesAsUnified(): List<UnifiedFileInterface>
val uri: Uri
val type: String?
fun lastModified(): Long?
val creationTime: Long?
val fileName: String?
fun getContentUri(appContext: AppContext): Uri
fun outputStream(append: Boolean = false): java.io.OutputStream
fun inputStream(): java.io.InputStream
fun length(): Long
fun walkTopDown(): Sequence<UnifiedFileInterface>
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<files-path name="expo_files" path="." />
<cache-path name="cached_expo_files" path="." />
</paths>