From 12d7c5b9962c69ead5da975dc3d7476ef20da1a2 Mon Sep 17 00:00:00 2001 From: Hai Zhang Date: Tue, 26 Mar 2024 21:40:49 -0700 Subject: [PATCH] Feat: Add Kotlin file system API --- app/build.gradle | 1 + .../zhanghai/kotlin/filesystem/AccessMode.kt | 7 + .../kotlin/filesystem/BasicCopyFileOption.kt | 7 + .../filesystem/BasicDirectoryStreamOption.kt | 6 + .../filesystem/BasicFileContentOption.kt | 10 + .../kotlin/filesystem/CopyFileOption.kt | 3 + .../kotlin/filesystem/CreateFileOption.kt | 3 + .../kotlin/filesystem/DirectoryEntry.kt | 11 + .../kotlin/filesystem/DirectoryStream.kt | 7 + .../filesystem/DirectoryStreamOption.kt | 3 + .../zhanghai/kotlin/filesystem/FileContent.kt | 82 +++ .../kotlin/filesystem/FileContentOption.kt | 3 + .../kotlin/filesystem/FileMetadata.kt | 15 + .../kotlin/filesystem/FileMetadataOption.kt | 3 + .../kotlin/filesystem/FileMetadataView.kt | 13 + .../zhanghai/kotlin/filesystem/FileStore.kt | 7 + .../kotlin/filesystem/FileStoreMetadata.kt | 15 + .../zhanghai/kotlin/filesystem/FileSystem.kt | 118 +++++ .../kotlin/filesystem/FileSystemException.kt | 10 + .../kotlin/filesystem/FileSystemExceptions.kt | 57 ++ .../kotlin/filesystem/FileSystemProvider.kt | 7 + .../kotlin/filesystem/FileSystemRegistry.kt | 31 ++ .../me/zhanghai/kotlin/filesystem/FileType.kt | 8 + .../zhanghai/kotlin/filesystem/LinkOption.kt | 5 + .../me/zhanghai/kotlin/filesystem/Path.kt | 253 +++++++++ .../me/zhanghai/kotlin/filesystem/Paths.kt | 118 +++++ .../kotlin/filesystem/PlatformFileSystem.kt | 13 + .../java/me/zhanghai/kotlin/filesystem/Uri.kt | 302 +++++++++++ .../kotlin/filesystem/internal/ByteStrings.kt | 13 + .../kotlin/filesystem/internal/CharMask.kt | 63 +++ .../kotlin/filesystem/internal/IntPair.kt | 18 + .../kotlin/filesystem/internal/Lists.kt | 37 ++ .../kotlin/filesystem/internal/UriParser.kt | 486 ++++++++++++++++++ .../kotlin/filesystem/io/AsyncCloseable.kt | 36 ++ .../kotlin/filesystem/io/AsyncFlushable.kt | 8 + .../kotlin/filesystem/io/AsyncSink.kt | 31 ++ .../kotlin/filesystem/io/AsyncSource.kt | 26 + .../me/zhanghai/kotlin/filesystem/io/Sink.kt | 27 + .../zhanghai/kotlin/filesystem/io/Source.kt | 24 + .../filesystem/posix/PosixFileMetadata.kt | 23 + .../filesystem/posix/PosixFileMetadataView.kt | 11 + .../kotlin/filesystem/posix/PosixFileType.kt | 12 + .../kotlin/filesystem/posix/PosixModeBit.kt | 13 + .../filesystem/posix/PosixModeOption.kt | 5 + 44 files changed, 1951 insertions(+) create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/AccessMode.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/BasicCopyFileOption.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/BasicDirectoryStreamOption.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/BasicFileContentOption.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/CopyFileOption.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/CreateFileOption.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/DirectoryEntry.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/DirectoryStream.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/DirectoryStreamOption.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/FileContent.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/FileContentOption.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/FileMetadata.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/FileMetadataOption.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/FileMetadataView.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/FileStore.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/FileStoreMetadata.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/FileSystem.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/FileSystemException.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/FileSystemExceptions.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/FileSystemProvider.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/FileSystemRegistry.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/FileType.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/LinkOption.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/Path.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/Paths.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/PlatformFileSystem.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/Uri.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/internal/ByteStrings.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/internal/CharMask.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/internal/IntPair.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/internal/Lists.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/internal/UriParser.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/io/AsyncCloseable.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/io/AsyncFlushable.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/io/AsyncSink.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/io/AsyncSource.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/io/Sink.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/io/Source.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/posix/PosixFileMetadata.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/posix/PosixFileMetadataView.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/posix/PosixFileType.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/posix/PosixModeBit.kt create mode 100644 app/src/main/java/me/zhanghai/kotlin/filesystem/posix/PosixModeOption.kt diff --git a/app/build.gradle b/app/build.gradle index 4c02e423e..b20e0b586 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -119,6 +119,7 @@ dependencies { dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'org.jetbrains.kotlinx:kotlinx-io-core:0.3.2' // kotlinx-coroutines-android depends on kotlin-stdlib-jdk8 implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" def kotlinx_coroutines_version = '1.8.0' diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/AccessMode.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/AccessMode.kt new file mode 100644 index 000000000..83c8cb4d9 --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/AccessMode.kt @@ -0,0 +1,7 @@ +package me.zhanghai.kotlin.filesystem + +public enum class AccessMode { + READ, + WRITE, + EXECUTE +} diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/BasicCopyFileOption.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/BasicCopyFileOption.kt new file mode 100644 index 000000000..a8a7c96f1 --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/BasicCopyFileOption.kt @@ -0,0 +1,7 @@ +package me.zhanghai.kotlin.filesystem + +public enum class BasicCopyFileOption : CopyFileOption { + REPLACE_EXISTING, + COPY_METADATA, + ATOMIC_MOVE +} diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/BasicDirectoryStreamOption.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/BasicDirectoryStreamOption.kt new file mode 100644 index 000000000..9113ed3cf --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/BasicDirectoryStreamOption.kt @@ -0,0 +1,6 @@ +package me.zhanghai.kotlin.filesystem + +public enum class BasicDirectoryStreamOption : DirectoryStreamOption { + READ_TYPE, + READ_METADATA +} diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/BasicFileContentOption.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/BasicFileContentOption.kt new file mode 100644 index 000000000..71a85f76c --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/BasicFileContentOption.kt @@ -0,0 +1,10 @@ +package me.zhanghai.kotlin.filesystem + +public enum class BasicFileContentOption : FileContentOption { + READ, + WRITE, + APPEND, + TRUNCATE_EXISTING, + CREATE, + CREATE_NEW +} diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/CopyFileOption.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/CopyFileOption.kt new file mode 100644 index 000000000..bffadaf9e --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/CopyFileOption.kt @@ -0,0 +1,3 @@ +package me.zhanghai.kotlin.filesystem + +public interface CopyFileOption diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/CreateFileOption.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/CreateFileOption.kt new file mode 100644 index 000000000..0c0ad1f8a --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/CreateFileOption.kt @@ -0,0 +1,3 @@ +package me.zhanghai.kotlin.filesystem + +public interface CreateFileOption : FileContentOption diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/DirectoryEntry.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/DirectoryEntry.kt new file mode 100644 index 000000000..35b1c233a --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/DirectoryEntry.kt @@ -0,0 +1,11 @@ +package me.zhanghai.kotlin.filesystem + +import kotlinx.io.bytestring.ByteString + +public interface DirectoryEntry { + public val name: ByteString + + public val type: FileType? + + public val metadata: FileMetadata? +} diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/DirectoryStream.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/DirectoryStream.kt new file mode 100644 index 000000000..f827d39ac --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/DirectoryStream.kt @@ -0,0 +1,7 @@ +package me.zhanghai.kotlin.filesystem + +import me.zhanghai.kotlin.filesystem.io.AsyncCloseable + +public interface DirectoryStream : AsyncCloseable { + public suspend fun read(): DirectoryEntry? +} diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/DirectoryStreamOption.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/DirectoryStreamOption.kt new file mode 100644 index 000000000..db10341ec --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/DirectoryStreamOption.kt @@ -0,0 +1,3 @@ +package me.zhanghai.kotlin.filesystem + +public interface DirectoryStreamOption diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/FileContent.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/FileContent.kt new file mode 100644 index 000000000..4b7aa77a1 --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/FileContent.kt @@ -0,0 +1,82 @@ +package me.zhanghai.kotlin.filesystem + +import kotlinx.io.Buffer +import kotlinx.io.IOException +import me.zhanghai.kotlin.filesystem.io.AsyncCloseable +import me.zhanghai.kotlin.filesystem.io.AsyncSink +import me.zhanghai.kotlin.filesystem.io.AsyncSource +import kotlin.concurrent.Volatile +import kotlin.coroutines.cancellation.CancellationException + +public interface FileContent : AsyncCloseable { + @Throws(CancellationException::class, IOException::class) + public suspend fun readAtMostTo(position: Long, sink: Buffer, byteCount: Long): Long + + @Throws(CancellationException::class, IOException::class) + public suspend fun write(position: Long, source: Buffer, byteCount: Long) + + @Throws(CancellationException::class, IOException::class) public suspend fun getSize() + + @Throws(CancellationException::class, IOException::class) public suspend fun setSize(size: Long) + + @Throws(CancellationException::class, IOException::class) public suspend fun sync() +} + +public fun FileContent.openSource(position: Long = 0): AsyncSource = + FileContentSource(this, position) + +private class FileContentSource(private val fileContent: FileContent, private var position: Long) : + AsyncSource { + @Volatile private var closed: Boolean = false + + @Throws(CancellationException::class, IOException::class) + override suspend fun readAtMostTo(sink: Buffer, byteCount: Long): Long { + checkNotClosed() + val read = fileContent.readAtMostTo(position, sink, byteCount) + if (read != -1L) { + position += read + } + return read + } + + private fun checkNotClosed() { + if (!closed) { + throw IOException("Source is closed") + } + } + + @Throws(CancellationException::class, IOException::class) + override suspend fun close() { + closed = true + } +} + +public fun FileContent.openSink(position: Long = 0): AsyncSink = FileContentSink(this, position) + +private class FileContentSink(private val fileContent: FileContent, private var position: Long) : + AsyncSink { + @Volatile private var closed: Boolean = false + + @Throws(CancellationException::class, IOException::class) + override suspend fun write(source: Buffer, byteCount: Long) { + checkNotClosed() + fileContent.write(position, source, byteCount) + position += byteCount + } + + @Throws(CancellationException::class, IOException::class) + override suspend fun flush() { + checkNotClosed() + } + + private fun checkNotClosed() { + if (!closed) { + throw IOException("Sink is closed") + } + } + + @Throws(CancellationException::class, IOException::class) + override suspend fun close() { + closed = true + } +} diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/FileContentOption.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/FileContentOption.kt new file mode 100644 index 000000000..ab385a68c --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/FileContentOption.kt @@ -0,0 +1,3 @@ +package me.zhanghai.kotlin.filesystem + +public interface FileContentOption diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/FileMetadata.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/FileMetadata.kt new file mode 100644 index 000000000..6d4e1e02d --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/FileMetadata.kt @@ -0,0 +1,15 @@ +package me.zhanghai.kotlin.filesystem + +public interface FileMetadata { + public val id: Any + + public val type: FileType + + public val size: Long + + public val lastModificationTimeMillis: Long + + public val lastAccessTimeMillis: Long? + + public val creationTimeMillis: Long? +} diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/FileMetadataOption.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/FileMetadataOption.kt new file mode 100644 index 000000000..ce1e86de4 --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/FileMetadataOption.kt @@ -0,0 +1,3 @@ +package me.zhanghai.kotlin.filesystem + +public interface FileMetadataOption diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/FileMetadataView.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/FileMetadataView.kt new file mode 100644 index 000000000..4de26a260 --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/FileMetadataView.kt @@ -0,0 +1,13 @@ +package me.zhanghai.kotlin.filesystem + +import me.zhanghai.kotlin.filesystem.io.AsyncCloseable + +public interface FileMetadataView : AsyncCloseable { + public suspend fun readMetadata(): FileMetadata + + public suspend fun setTimes( + lastModificationTimeMillis: Long? = null, + lastAccessTimeMillis: Long? = null, + creationTimeMillis: Long? = null + ) +} diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/FileStore.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/FileStore.kt new file mode 100644 index 000000000..4ea4cc910 --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/FileStore.kt @@ -0,0 +1,7 @@ +package me.zhanghai.kotlin.filesystem + +import me.zhanghai.kotlin.filesystem.io.AsyncCloseable + +public interface FileStore : AsyncCloseable { + public suspend fun readMetadata(): FileStoreMetadata +} diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/FileStoreMetadata.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/FileStoreMetadata.kt new file mode 100644 index 000000000..d3981032f --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/FileStoreMetadata.kt @@ -0,0 +1,15 @@ +package me.zhanghai.kotlin.filesystem + +import kotlinx.io.bytestring.ByteString + +public interface FileStoreMetadata { + public val type: ByteString + + public val blockSize: Long + + public val totalSpace: Long + + public val freeSpace: Long + + public val availableSpace: Long +} diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/FileSystem.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/FileSystem.kt new file mode 100644 index 000000000..6c1a3cdad --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/FileSystem.kt @@ -0,0 +1,118 @@ +package me.zhanghai.kotlin.filesystem + +import kotlinx.io.IOException +import kotlinx.io.bytestring.ByteString +import me.zhanghai.kotlin.filesystem.io.AsyncSink +import me.zhanghai.kotlin.filesystem.io.AsyncSource +import me.zhanghai.kotlin.filesystem.io.use +import me.zhanghai.kotlin.filesystem.io.withCloseable +import kotlin.coroutines.cancellation.CancellationException + +public interface FileSystem { + public val rootUri: Uri + + public val rootDirectory: Path + get() = Path.of(rootUri, true, emptyList()) + + public val defaultDirectory: Path + + @Throws(CancellationException::class, IOException::class) + public suspend fun getRealPath(path: Path): Path + + @Throws(CancellationException::class, IOException::class) + public suspend fun checkAccess(path: Path, vararg modes: AccessMode) + + @Throws(CancellationException::class, IOException::class) + public suspend fun openMetadataView( + file: Path, + vararg options: FileMetadataOption + ): FileMetadataView + + @Throws(CancellationException::class, IOException::class) + public suspend fun readMetadata(file: Path, vararg options: FileMetadataOption): FileMetadata = + openMetadataView(file, *options).use { it.readMetadata() } + + @Throws(CancellationException::class, IOException::class) + public suspend fun openContent(file: Path, vararg options: FileContentOption): FileContent + + @Throws(CancellationException::class, IOException::class) + public suspend fun openSource(file: Path, vararg options: FileContentOption): AsyncSource { + require(BasicFileContentOption.WRITE !in options) { BasicFileContentOption.WRITE } + require(BasicFileContentOption.APPEND !in options) { BasicFileContentOption.APPEND } + return openContent(file, *options).let { it.openSource().withCloseable(it) } + } + + @Throws(CancellationException::class, IOException::class) + public suspend fun openSink( + file: Path, + vararg options: FileContentOption = OPEN_SINK_OPTIONS_DEFAULT + ): AsyncSink { + require(BasicFileContentOption.READ !in options) { BasicFileContentOption.READ } + require( + BasicFileContentOption.WRITE in options || BasicFileContentOption.APPEND in options + ) { + "Missing ${BasicFileContentOption.WRITE} or ${BasicFileContentOption.APPEND}" + } + return openContent(file, *options).let { it.openSink().withCloseable(it) } + } + + @Throws(CancellationException::class, IOException::class) + public suspend fun openDirectoryStream( + directory: Path, + vararg options: DirectoryStreamOption + ): DirectoryStream + + @Throws(CancellationException::class, IOException::class) + public suspend fun readDirectory( + directory: Path, + vararg options: DirectoryStreamOption + ): List = + openDirectoryStream(directory, *options).use { directoryStream -> + buildList { + while (true) { + val directoryEntry = directoryStream.read() ?: break + this += directory.resolve(directoryEntry.name) + } + } + } + + @Throws(CancellationException::class, IOException::class) + public suspend fun createDirectory(directory: Path, vararg options: CreateFileOption) + + @Throws(CancellationException::class, IOException::class) + public suspend fun readSymbolicLink(link: Path): ByteString + + @Throws(CancellationException::class, IOException::class) + public suspend fun createSymbolicLink( + link: Path, + target: ByteString, + vararg options: CreateFileOption + ) + + @Throws(CancellationException::class, IOException::class) + public suspend fun createHardLink(link: Path, existing: Path) + + @Throws(CancellationException::class, IOException::class) public suspend fun delete(path: Path) + + @Throws(CancellationException::class, IOException::class) + public suspend fun isSameFile(path1: Path, path2: Path): Boolean + + @Throws(CancellationException::class, IOException::class) + public suspend fun copy(source: Path, target: Path, vararg options: CopyFileOption) + + @Throws(CancellationException::class, IOException::class) + public suspend fun move(source: Path, target: Path, vararg options: CopyFileOption) + + @Throws(CancellationException::class, IOException::class) + public suspend fun openFileStore(path: Path): FileStore + + public companion object { + @PublishedApi + internal val OPEN_SINK_OPTIONS_DEFAULT: Array = + arrayOf( + BasicFileContentOption.WRITE, + BasicFileContentOption.TRUNCATE_EXISTING, + BasicFileContentOption.CREATE + ) + } +} diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/FileSystemException.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/FileSystemException.kt new file mode 100644 index 000000000..b73bccfa1 --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/FileSystemException.kt @@ -0,0 +1,10 @@ +package me.zhanghai.kotlin.filesystem + +import kotlinx.io.IOException + +public open class FileSystemException( + public val file: Path?, + public val otherFile: Path? = null, + message: String? = null, + cause: Throwable? = null +) : IOException(message, cause) diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/FileSystemExceptions.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/FileSystemExceptions.kt new file mode 100644 index 000000000..25bcc1326 --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/FileSystemExceptions.kt @@ -0,0 +1,57 @@ +package me.zhanghai.kotlin.filesystem + +public class AccessDeniedException( + file: Path?, + otherFile: Path? = null, + message: String? = null, + cause: Throwable? = null +) : FileSystemException(file, otherFile, message, cause) + +public class AtomicMoveNotSupportedException( + file: Path?, + otherFile: Path? = null, + message: String? = null, + cause: Throwable? = null +) : FileSystemException(file, otherFile, message, cause) + +public class DirectoryNotEmptyException( + file: Path?, + otherFile: Path? = null, + message: String? = null, + cause: Throwable? = null +) : FileSystemException(file, otherFile, message, cause) + +public class FileAlreadyExistsException( + file: Path?, + otherFile: Path? = null, + message: String? = null, + cause: Throwable? = null +) : FileSystemException(file, otherFile, message, cause) + +public class FileSystemLoopException( + file: Path?, + otherFile: Path? = null, + message: String? = null, + cause: Throwable? = null +) : FileSystemException(file, otherFile, message, cause) + +public class NoSuchFileException( + file: Path?, + otherFile: Path? = null, + message: String? = null, + cause: Throwable? = null +) : FileSystemException(file, otherFile, message, cause) + +public class NotDirectoryException( + file: Path?, + otherFile: Path? = null, + message: String? = null, + cause: Throwable? = null +) : FileSystemException(file, otherFile, message, cause) + +public class NotLinkException( + file: Path?, + otherFile: Path? = null, + message: String? = null, + cause: Throwable? = null +) : FileSystemException(file, otherFile, message, cause) diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/FileSystemProvider.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/FileSystemProvider.kt new file mode 100644 index 000000000..e42e62984 --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/FileSystemProvider.kt @@ -0,0 +1,7 @@ +package me.zhanghai.kotlin.filesystem + +public interface FileSystemProvider { + public val scheme: String + + public fun createFileSystem(rootUri: Uri): FileSystem +} diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/FileSystemRegistry.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/FileSystemRegistry.kt new file mode 100644 index 000000000..01c591b3a --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/FileSystemRegistry.kt @@ -0,0 +1,31 @@ +package me.zhanghai.kotlin.filesystem + +// TODO: Make thread safe +public object FileSystemRegistry { + private val providers: MutableMap = mutableMapOf() + + private val fileSystems: MutableMap = mutableMapOf() + + public fun getProviders(): Map = providers.toMap() + + public fun getProvider(scheme: String): FileSystemProvider? = providers[scheme] + + public fun removeProvider(scheme: String): FileSystemProvider? = providers.remove(scheme) + + public fun getFileSystems(): Map = fileSystems.toMap() + + public fun getFileSystem(rootUri: Uri): FileSystem? = fileSystems[rootUri] + + public fun getOrCreateFileSystem(rootUri: Uri): FileSystem { + fileSystems[rootUri]?.let { + return it + } + val provider = providers[rootUri.scheme] + requireNotNull(provider) { "No file system provider for scheme \"${rootUri.scheme}\"" } + return provider.createFileSystem(rootUri) + } + + public fun removeFileSystem(rootUri: Uri): FileSystem? = fileSystems.remove(rootUri) +} + +public expect val FileSystemRegistry.platformFileSystem: PlatformFileSystem diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/FileType.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/FileType.kt new file mode 100644 index 000000000..70f7f43ea --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/FileType.kt @@ -0,0 +1,8 @@ +package me.zhanghai.kotlin.filesystem + +public enum class FileType { + REGULAR_FILE, + DIRECTORY, + SYMBOLIC_LINK, + OTHER +} diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/LinkOption.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/LinkOption.kt new file mode 100644 index 000000000..257f74f2c --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/LinkOption.kt @@ -0,0 +1,5 @@ +package me.zhanghai.kotlin.filesystem + +public enum class LinkOption : FileContentOption, FileMetadataOption { + NO_FOLLOW_LINKS +} diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/Path.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/Path.kt new file mode 100644 index 000000000..36f679771 --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/Path.kt @@ -0,0 +1,253 @@ +package me.zhanghai.kotlin.filesystem + +import kotlinx.io.bytestring.ByteString +import kotlinx.io.bytestring.append +import kotlinx.io.bytestring.buildByteString +import kotlinx.io.bytestring.encodeToByteString +import kotlinx.io.bytestring.isNotEmpty +import me.zhanghai.kotlin.filesystem.internal.compareTo +import me.zhanghai.kotlin.filesystem.internal.contains +import me.zhanghai.kotlin.filesystem.internal.endsWith +import me.zhanghai.kotlin.filesystem.internal.startsWith +import kotlin.math.min + +public class Path +private constructor( + public val rootUri: Uri, + public val isAbsolute: Boolean, + public val names: List, + @Suppress("UNUSED_PARAMETER") any: Any? +) : Comparable { + public val scheme: String + get() = rootUri.scheme!! + + public val fileName: ByteString? + get() = names.lastOrNull() + + public fun getParent(): Path? { + val lastIndex = names.lastIndex + return if (lastIndex >= 0) { + Path(rootUri, isAbsolute, names.subList(0, lastIndex), null) + } else { + null + } + } + + public fun subPath(startIndex: Int, endIndex: Int): Path = + if (startIndex == 0 && endIndex == names.size) { + this + } else { + Path(rootUri, isAbsolute, names.subList(startIndex, endIndex), null) + } + + public fun startsWith(other: Path): Boolean { + if (this === other) { + return true + } + if (rootUri != other.rootUri) { + return false + } + return isAbsolute == other.isAbsolute && names.startsWith(other.names) + } + + public fun endsWith(other: Path): Boolean { + if (this === other) { + return true + } + if (rootUri != other.rootUri) { + return false + } + return if (other.isAbsolute) { + isAbsolute && names == other.names + } else { + names.endsWith(other.names) + } + } + + public fun normalize(): Path { + var newNames: MutableList? = null + for ((index, name) in names.withIndex()) { + when (name) { + NAME_DOT -> + if (newNames == null) { + newNames = names.subList(0, index).toMutableList() + } + NAME_DOT_DOT -> + if (newNames != null) { + when (newNames.lastOrNull()) { + null -> + if (!isAbsolute) { + newNames += name + } + NAME_DOT_DOT -> newNames += name + else -> newNames.removeLast() + } + } else { + when (names.getOrNull(index - 1)) { + null -> + if (isAbsolute) { + newNames = mutableListOf() + } + NAME_DOT_DOT -> {} + else -> newNames = names.subList(0, index - 1).toMutableList() + } + } + else -> + if (newNames != null) { + newNames += name + } + } + } + if (newNames == null) { + return this + } + return Path(rootUri, isAbsolute, newNames, null) + } + + public fun resolve(fileName: ByteString): Path = + Path(rootUri, isAbsolute, names + fileName, null) + + public fun resolve(other: Path): Path { + require(rootUri == other.rootUri) { "Cannot resolve a path with a different root URI" } + return if (other.isAbsolute) { + other + } else { + Path(rootUri, isAbsolute, names + other.names, null) + } + } + + public fun resolveSibling(fileName: ByteString): Path { + check(names.isNotEmpty()) { "Cannot resolve sibling of an empty path" } + return Path( + rootUri, + isAbsolute, + names.toMutableList().apply { set(lastIndex, fileName) }, + null + ) + } + + public fun relativize(other: Path): Path { + if (this === other) { + return Path(rootUri, false, emptyList(), null) + } + require(rootUri == other.rootUri) { "Cannot relativize a path with a different root URI" } + require(isAbsolute == other.isAbsolute) { + "Cannot relativize a path with a different absoluteness" + } + if (names.isEmpty()) { + return if (other.isAbsolute) { + Path(rootUri, false, other.names, null) + } else { + other + } + } + val namesSize = names.size + val otherNamesSize = other.names.size + val minNamesSize = min(namesSize, otherNamesSize) + var commonNamesSize = 0 + while (commonNamesSize < minNamesSize) { + if (names[commonNamesSize] != other.names[commonNamesSize]) { + break + } + ++commonNamesSize + } + val newNames = names.subList(0, commonNamesSize).toMutableList() + repeat(namesSize - commonNamesSize) { newNames += NAME_DOT_DOT } + newNames += other.names.subList(commonNamesSize, otherNamesSize) + return Path(rootUri, false, newNames, null) + } + + public fun toUri(): Uri { + check(isAbsolute) { "Cannot convert a relative path to URI" } + val decodedPath = buildByteString { + for (name in names) { + append(NAME_SEPARATOR_BYTE) + append(name) + } + } + return rootUri.copyDecoded(decodedPath = decodedPath) + } + + override fun compareTo(other: Path): Int { + rootUri.compareTo(other.rootUri).let { + if (it != 0) { + return it + } + } + isAbsolute.compareTo(other.isAbsolute).let { + if (it != 0) { + return it + } + } + return names.compareTo(other.names) + } + + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + if (other == null || this::class != other::class) { + return false + } + other as Path + return rootUri == other.rootUri && isAbsolute == other.isAbsolute && names == other.names + } + + override fun hashCode(): Int { + var result = rootUri.hashCode() + result = 31 * result + isAbsolute.hashCode() + result = 31 * result + names.hashCode() + return result + } + + override fun toString(): String = "Path(rootUri=$rootUri, isAbsolute=$isAbsolute, names=$names)" + + public companion object { + private val NAME_DOT = ".".encodeToByteString() + private val NAME_DOT_DOT = "..".encodeToByteString() + private const val NAME_SEPARATOR_CHAR = '/' + private const val NAME_SEPARATOR_BYTE = NAME_SEPARATOR_CHAR.code.toByte() + private const val NAME_SEPARATOR_STRING = "/" + + public fun of(rootUri: Uri, isAbsolute: Boolean, names: List): Path { + requireNotNull(rootUri.scheme) { "Missing scheme in path root URI \"$rootUri\"" } + require(rootUri.encodedPath == NAME_SEPARATOR_STRING) { + "Path is not root in path root URI \"$rootUri\"" + } + for (name in names) { + require(name.isNotEmpty()) { "Empty name in path name \"$name\$" } + require(NAME_SEPARATOR_BYTE !in name) { "Name separator in path name \"$name\"" } + } + return Path(rootUri, isAbsolute, names, null) + } + + public fun fromUri(uri: Uri): Path { + requireNotNull(uri.scheme) { "Missing scheme in path URI \"$uri\"" } + val encodedPath = uri.encodedPath + require(encodedPath.isNotEmpty()) { "Empty path in path URI \"$uri\"" } + require(encodedPath[0] == NAME_SEPARATOR_CHAR) { "Relative path in path URI \"$uri\"" } + val rootUri = uri.copyEncoded(encodedPath = NAME_SEPARATOR_STRING) + val names = buildList { + val decodedPath = uri.decodedPath + val decodedPathSize = decodedPath.size + var nameStart = 0 + var nameEnd = nameStart + while (nameEnd < decodedPathSize) { + if (decodedPath[nameEnd] == NAME_SEPARATOR_BYTE) { + if (nameEnd != nameStart) { + this += decodedPath.substring(nameStart, nameEnd) + } + nameStart = nameEnd + 1 + nameEnd = nameStart + } else { + ++nameEnd + } + } + if (nameEnd != nameStart) { + this += decodedPath.substring(nameStart, nameEnd) + } + } + return Path(rootUri, true, names, null) + } + } +} diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/Paths.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/Paths.kt new file mode 100644 index 000000000..998acfd15 --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/Paths.kt @@ -0,0 +1,118 @@ +package me.zhanghai.kotlin.filesystem + +import kotlinx.io.IOException +import kotlinx.io.bytestring.ByteString +import kotlinx.io.bytestring.encodeToByteString +import me.zhanghai.kotlin.filesystem.io.AsyncSink +import me.zhanghai.kotlin.filesystem.io.AsyncSource +import kotlin.coroutines.cancellation.CancellationException + +public inline fun Path.Companion.fromPlatformPath(platformPath: ByteString): Path = + FileSystemRegistry.platformFileSystem.getPath(platformPath) + +public inline fun Path.Companion.fromPlatformPath(platformPath: String): Path = + fromPlatformPath(platformPath.encodeToByteString()) + +public inline fun Path.toPlatformPath(): ByteString = + FileSystemRegistry.platformFileSystem.toPlatformPath(this) + +public inline fun Path.toPlatformPathString(): String = toPlatformPath().toString() + +public inline fun Path.getOrCreateFileSystem(): FileSystem = + FileSystemRegistry.getOrCreateFileSystem(this.rootUri) + +public inline fun Path.toAbsolutePath(): Path = + getOrCreateFileSystem().defaultDirectory.resolve(this) + +@Throws(CancellationException::class, IOException::class) +public suspend inline fun Path.toRealPath(): Path = getOrCreateFileSystem().getRealPath(this) + +@Throws(CancellationException::class, IOException::class) +public suspend inline fun Path.checkAccess(vararg modes: AccessMode) { + getOrCreateFileSystem().checkAccess(this, *modes) +} + +@Throws(CancellationException::class, IOException::class) +public suspend inline fun Path.openMetadataView( + vararg options: FileMetadataOption +): FileMetadataView = getOrCreateFileSystem().openMetadataView(this, *options) + +@Throws(CancellationException::class, IOException::class) +public suspend inline fun Path.readMetadata(vararg options: FileMetadataOption): FileMetadata = + getOrCreateFileSystem().readMetadata(this, *options) + +@Throws(CancellationException::class, IOException::class) +public suspend inline fun Path.openContent(vararg options: FileContentOption): FileContent = + getOrCreateFileSystem().openContent(this, *options) + +@Throws(CancellationException::class, IOException::class) +public suspend inline fun Path.openSource(vararg options: FileContentOption): AsyncSource = + getOrCreateFileSystem().openSource(this, *options) + +@Throws(CancellationException::class, IOException::class) +public suspend inline fun Path.openSink( + vararg options: FileContentOption = FileSystem.OPEN_SINK_OPTIONS_DEFAULT +): AsyncSink = getOrCreateFileSystem().openSink(this, *options) + +@Throws(CancellationException::class, IOException::class) +public suspend inline fun Path.openDirectoryStream( + vararg options: DirectoryStreamOption +): DirectoryStream = getOrCreateFileSystem().openDirectoryStream(this, *options) + +@Throws(CancellationException::class, IOException::class) +public suspend inline fun Path.readDirectory(vararg options: DirectoryStreamOption): List = + getOrCreateFileSystem().readDirectory(this, *options) + +@Throws(CancellationException::class, IOException::class) +public suspend inline fun Path.createDirectory(vararg options: CreateFileOption) { + getOrCreateFileSystem().createDirectory(this, *options) +} + +@Throws(CancellationException::class, IOException::class) +public suspend inline fun Path.readSymbolicLink(): ByteString = + getOrCreateFileSystem().readSymbolicLink(this) + +@Throws(CancellationException::class, IOException::class) +public suspend inline fun Path.createSymbolicLinkTo( + target: ByteString, + vararg options: CreateFileOption +) { + getOrCreateFileSystem().createSymbolicLink(this, target, *options) +} + +@Throws(CancellationException::class, IOException::class) +public suspend inline fun Path.createHardLinkTo(existing: Path) { + getOrCreateFileSystem().createHardLink(this, existing) +} + +@Throws(CancellationException::class, IOException::class) +public suspend inline fun Path.delete() { + getOrCreateFileSystem().delete(this) +} + +@Throws(CancellationException::class, IOException::class) +public suspend inline fun Path.deleteIfExists(): Boolean = + try { + delete() + true + } catch (e: NoSuchFileException) { + false + } + +@Throws(CancellationException::class, IOException::class) +public suspend inline fun Path.isSameFileAs(other: Path): Boolean = + getOrCreateFileSystem().isSameFile(this, other) + +@Throws(CancellationException::class, IOException::class) +public suspend inline fun Path.copyTo(target: Path, vararg options: CopyFileOption) { + getOrCreateFileSystem().copy(this, target, *options) +} + +@Throws(CancellationException::class, IOException::class) +public suspend inline fun Path.moveTo(target: Path, vararg options: CopyFileOption) { + getOrCreateFileSystem().move(this, target, *options) +} + +@Throws(CancellationException::class, IOException::class) +public suspend inline fun Path.openFileStore(): FileStore = + getOrCreateFileSystem().openFileStore(this) diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/PlatformFileSystem.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/PlatformFileSystem.kt new file mode 100644 index 000000000..e1513f8b6 --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/PlatformFileSystem.kt @@ -0,0 +1,13 @@ +package me.zhanghai.kotlin.filesystem + +import kotlinx.io.bytestring.ByteString + +public interface PlatformFileSystem : FileSystem { + public fun getPath(platformPath: ByteString): Path + + public fun toPlatformPath(path: Path): ByteString + + public companion object { + public const val SCHEME: String = "file" + } +} diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/Uri.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/Uri.kt new file mode 100644 index 000000000..41dff9abd --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/Uri.kt @@ -0,0 +1,302 @@ +package me.zhanghai.kotlin.filesystem + +import kotlinx.io.bytestring.ByteString +import kotlinx.io.bytestring.indices +import me.zhanghai.kotlin.filesystem.internal.UriParser +import kotlin.experimental.and + +// https://datatracker.ietf.org/doc/html/rfc3986 +public class Uri +internal constructor( + public val scheme: String?, + public val encodedUserInfo: String?, + public val encodedHost: String?, + public val port: Int?, + public val encodedPath: String, + public val encodedQuery: String?, + public val encodedFragment: String?, + @Suppress("UNUSED_PARAMETER") any: Any? +) : Comparable { + public val encodedAuthority: String? by + lazy(LazyThreadSafetyMode.NONE) { + if (encodedUserInfo != null || encodedHost != null || port != null) { + buildString { + if (encodedUserInfo != null) { + append(encodedUserInfo) + append('@') + } + if (encodedHost != null) { + append(encodedHost) + } + if (port != null) { + append(':') + append(port) + } + } + } else { + null + } + } + + public val decodedAuthority: ByteString? by + lazy(LazyThreadSafetyMode.NONE) { encodedAuthority?.let { UriParser.decodePart(it) } } + + public val decodedUserInfo: ByteString? by + lazy(LazyThreadSafetyMode.NONE) { encodedUserInfo?.let { UriParser.decodePart(it) } } + + public val decodedHost: ByteString? by + lazy(LazyThreadSafetyMode.NONE) { encodedHost?.let { UriParser.decodePart(it) } } + + public val decodedPath: ByteString by + lazy(LazyThreadSafetyMode.NONE) { encodedPath.let { UriParser.decodePart(it) } } + + public val decodedQuery: ByteString? by + lazy(LazyThreadSafetyMode.NONE) { encodedQuery?.let { UriParser.decodePart(it) } } + + public val decodedFragment: ByteString? by + lazy(LazyThreadSafetyMode.NONE) { encodedFragment?.let { UriParser.decodePart(it) } } + + public fun copyEncoded( + scheme: String? = this.scheme, + encodedUserInfo: String? = this.encodedUserInfo, + encodedHost: String? = this.encodedHost, + port: Int? = this.port, + encodedPath: String = this.encodedPath, + encodedQuery: String? = this.encodedQuery, + encodedFragment: String? = this.encodedFragment + ): Uri { + if (scheme !== this.scheme) { + UriParser.requireValidScheme(scheme) + } + if (encodedUserInfo !== this.encodedUserInfo) { + UriParser.requireValidEncodedUserInfo(encodedUserInfo) + } + if (encodedHost !== this.encodedHost) { + UriParser.requireValidEncodedHost(encodedHost) + } + UriParser.requireValidPort(port) + if (encodedPath != this.encodedPath) { + UriParser.requireValidEncodedPath(encodedPath, scheme == null) + } + if (encodedQuery != this.encodedQuery) { + UriParser.requireValidEncodedQuery(encodedQuery) + } + if (encodedFragment != this.encodedFragment) { + UriParser.requireValidEncodedFragment(encodedFragment) + } + return Uri( + scheme, + encodedUserInfo, + encodedHost, + port, + encodedPath, + encodedQuery, + encodedFragment, + null + ) + } + + public fun copyDecoded( + scheme: String? = this.scheme, + decodedUserInfo: ByteString? = BYTE_STRING_COPY, + decodedHost: ByteString? = BYTE_STRING_COPY, + port: Int? = this.port, + decodedPath: ByteString = BYTE_STRING_COPY, + decodedQuery: ByteString? = BYTE_STRING_COPY, + decodedFragment: ByteString? = BYTE_STRING_COPY + ): Uri { + if (scheme !== this.scheme) { + UriParser.requireValidScheme(scheme) + } + val encodedUserInfo = + if (decodedUserInfo === BYTE_STRING_COPY) { + encodedUserInfo + } else { + decodedUserInfo?.let { UriParser.encodeUserInfo(it) } + } + val encodedHost = + if (decodedHost === BYTE_STRING_COPY) { + encodedHost + } else { + decodedHost?.let { UriParser.encodeHost(it) } + } + UriParser.requireValidPort(port) + val encodedPath = + if (decodedPath === BYTE_STRING_COPY) { + encodedPath + } else { + decodedPath + .let { UriParser.encodePath(it) } + .also { UriParser.requireValidEncodedPath(it, scheme == null) } + } + val encodedQuery = + if (decodedQuery === BYTE_STRING_COPY) { + encodedQuery + } else { + decodedQuery?.let { UriParser.encodeQuery(it) } + } + val encodedFragment = + if (decodedFragment === BYTE_STRING_COPY) { + encodedFragment + } else { + decodedFragment?.let { UriParser.encodeFragment(it) } + } + return Uri( + scheme, + encodedUserInfo, + encodedHost, + port, + encodedPath, + encodedQuery, + encodedFragment, + null + ) + } + + private val string: String by + lazy(LazyThreadSafetyMode.NONE) { + buildString { + if (scheme != null) { + append(scheme) + append(':') + } + if (encodedHost != null) { + append("//") + if (encodedUserInfo != null) { + append(encodedUserInfo) + append('@') + } + append(encodedHost) + if (port != null) { + append(':') + append(port) + } + } + append(encodedPath) + if (encodedQuery != null) { + append('?') + append(encodedQuery) + } + if (encodedFragment != null) { + append('#') + append(encodedFragment) + } + } + } + + override fun compareTo(other: Uri): Int = string.compareTo(other.string) + + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + if (other == null || this::class != other::class) { + return false + } + other as Uri + return string == other.string + } + + override fun hashCode(): Int = string.hashCode() + + override fun toString(): String = string + + public companion object { + public fun ofEncoded( + scheme: String? = null, + encodedUserInfo: String? = null, + encodedHost: String? = null, + port: Int? = null, + encodedPath: String = "", + encodedQuery: String? = null, + encodedFragment: String? = null + ): Uri { + UriParser.requireValidScheme(scheme) + UriParser.requireValidEncodedUserInfo(encodedUserInfo) + UriParser.requireValidEncodedHost(encodedHost) + UriParser.requireValidPort(port) + UriParser.requireValidEncodedPath(encodedPath, scheme == null) + UriParser.requireValidEncodedQuery(encodedQuery) + UriParser.requireValidEncodedFragment(encodedFragment) + return Uri( + scheme, + encodedUserInfo, + encodedHost, + port, + encodedPath, + encodedQuery, + encodedFragment, + null + ) + } + + public fun ofDecoded( + scheme: String? = null, + decodedUserInfo: ByteString? = null, + decodedHost: ByteString? = null, + port: Int? = null, + decodedPath: ByteString = BYTE_STRING_EMPTY, + decodedQuery: ByteString? = null, + decodedFragment: ByteString? = null + ): Uri { + UriParser.requireValidScheme(scheme) + val encodedUserInfo = decodedUserInfo?.let { UriParser.encodeUserInfo(it) } + val encodedHost = decodedHost?.let { UriParser.encodeHost(it) } + UriParser.requireValidPort(port) + val encodedPath = + decodedPath + .let { UriParser.encodePath(it) } + .also { UriParser.requireValidEncodedPath(it, scheme == null) } + val encodedQuery = decodedQuery?.let { UriParser.encodeQuery(it) } + val encodedFragment = decodedFragment?.let { UriParser.encodeFragment(it) } + return Uri( + scheme, + encodedUserInfo, + encodedHost, + port, + encodedPath, + encodedQuery, + encodedFragment, + null + ) + } + + public fun parse(uri: String): Uri = UriParser.parse(uri) + } +} + +private val BYTE_STRING_COPY = ByteString(0.toByte()) +private val BYTE_STRING_EMPTY = ByteString() + +private const val CHARSET_ALPHA: String = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +private const val CHARSET_DIGIT: String = "0123456789" +private const val CHARSET_UNRESERVED: String = "$CHARSET_ALPHA$CHARSET_DIGIT-._~" +private const val CHARSET_SUB_DELIMS: String = "!$&'()*+,;=" +private const val CHARSET_SCHEME: String = "$CHARSET_ALPHA$CHARSET_DIGIT+-." +private const val CHARSET_USERINFO: String = "$CHARSET_UNRESERVED$CHARSET_SUB_DELIMS:" +private const val CHARSET_REG_NAME: String = "$CHARSET_UNRESERVED$CHARSET_SUB_DELIMS" +private const val CHARSET_HOST: String = "$CHARSET_REG_NAME:[]" +private const val CHARSET_PCHAR: String = "$CHARSET_UNRESERVED$CHARSET_SUB_DELIMS:@" +private const val CHARSET_PATH: String = "$CHARSET_PCHAR/" +private const val CHARSET_QUERY: String = "$CHARSET_PCHAR/?" +private const val CHARSET_FRAGMENT: String = "$CHARSET_PCHAR/?" + +private fun ByteString.percentEncode(charset: String): String = buildString { + for (i in this@percentEncode.indices) { + val byte = this@percentEncode[i] + if (charset.indexOf(byte.toInt().toChar()) != -1) { + append(byte.toInt().toChar()) + } else { + append('%') + append((((byte.toInt() ushr 4).toByte() and 0x0F)).encodeHexChar()) + append((byte and 0x0F).encodeHexChar()) + } + } +} + +private fun Byte.encodeHexChar(): Char = + when (this) { + in 0..9 -> '0' + toInt() + in 10..15 -> 'A' + (toInt() - 10) + else -> throw IllegalArgumentException("Non-half byte $this for percent-encoding in URI") + } diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/internal/ByteStrings.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/internal/ByteStrings.kt new file mode 100644 index 000000000..ba8a5d030 --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/internal/ByteStrings.kt @@ -0,0 +1,13 @@ +package me.zhanghai.kotlin.filesystem.internal + +import kotlinx.io.bytestring.ByteString +import kotlinx.io.bytestring.indexOf + +internal inline operator fun ByteString.contains(byte: Byte): Boolean = indexOf(byte) != -1 + +internal inline fun ByteString.first(): Byte = this[0] + +internal inline fun ByteString.last(): Byte = this[lastIndex] + +internal val ByteString.lastIndex: Int + inline get() = size - 1 diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/internal/CharMask.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/internal/CharMask.kt new file mode 100644 index 000000000..3e0748a2c --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/internal/CharMask.kt @@ -0,0 +1,63 @@ +package me.zhanghai.kotlin.filesystem.internal + +internal class CharMask private constructor(private val mask64: Long, private val mask128: Long) { + fun matches(char: Char): Boolean = + when { + char < 64.toChar() -> (1L shl char.code) and mask64 != 0L + char < 128.toChar() -> (1L shl (char.code - 64)) and mask128 != 0L + else -> false + } + + infix fun and(other: CharMask): CharMask = + CharMask(mask64 and other.mask64, mask128 and other.mask128) + + fun inv(): CharMask = CharMask(mask64.inv(), mask128.inv()) + + infix fun or(other: CharMask): CharMask = + CharMask(mask64 or other.mask64, mask128 or other.mask128) + + companion object { + fun of(char: Char): CharMask { + var mask64 = 0L + var mask128 = 0L + when { + char < 64.toChar() -> mask64 = mask64 or (1L shl char.code) + char < 128.toChar() -> mask128 = mask128 or (1L shl (char.code - 64)) + else -> throw IllegalArgumentException("Non-ASCII char '$char'") + } + return CharMask(mask64, mask128) + } + + fun of(chars: String): CharMask { + var mask64 = 0L + var mask128 = 0L + for (char in chars) { + when { + char < 64.toChar() -> mask64 = mask64 or (1L shl char.code) + char < 128.toChar() -> mask128 = mask128 or (1L shl (char.code - 64)) + else -> throw IllegalArgumentException("Non-ASCII char '$char'") + } + } + return CharMask(mask64, mask128) + } + + fun ofRange(startChar: Char, endCharInclusive: Char): CharMask { + require(endCharInclusive < 128.toChar()) { + "Non-ASCII endCharInclusive ('$endCharInclusive')" + } + require(startChar <= endCharInclusive) { + "startChar ('$startChar') > endCharInclusive ('$endCharInclusive')" + } + var mask64 = 0L + var mask128 = 0L + for (char in startChar..endCharInclusive) { + if (char < 64.toChar()) { + mask64 = mask64 or (1L shl char.code) + } else { + mask128 = mask128 or (1L shl (char.code - 64)) + } + } + return CharMask(mask64, mask128) + } + } +} diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/internal/IntPair.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/internal/IntPair.kt new file mode 100644 index 000000000..cae62aed9 --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/internal/IntPair.kt @@ -0,0 +1,18 @@ +package me.zhanghai.kotlin.filesystem.internal + +import kotlin.jvm.JvmInline + +@JvmInline +internal value class IntPair private constructor(private val value: Long) { + constructor(first: Int, second: Int) : this((first.toLong() shl 32) or second.toLong()) + + val first: Int + inline get() = (value ushr 32).toInt() + + val second: Int + inline get() = value.toInt() + + inline operator fun component1(): Int = first + + inline operator fun component2(): Int = second +} diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/internal/Lists.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/internal/Lists.kt new file mode 100644 index 000000000..a1a96ad88 --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/internal/Lists.kt @@ -0,0 +1,37 @@ +package me.zhanghai.kotlin.filesystem.internal + +import kotlin.math.min + +internal fun > List.compareTo(other: List): Int { + val size = size + val otherSize = other.size + val commonSize = min(size, otherSize) + for (i in 0 ..< commonSize) { + this[i].compareTo(other[i]).let { + if (it != 0) { + return it + } + } + } + return size.compareTo(otherSize) +} + +internal fun List.startsWith(other: List): Boolean { + val size = size + val otherSize = other.size + return when { + size == otherSize -> this == other + size > otherSize -> subList(0, otherSize) == other + else -> false + } +} + +internal fun List.endsWith(other: List): Boolean { + val size = size + val otherSize = other.size + return when { + size == otherSize -> this == other + size > otherSize -> subList(size - otherSize, size) == other + else -> false + } +} diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/internal/UriParser.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/internal/UriParser.kt new file mode 100644 index 000000000..2a371254f --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/internal/UriParser.kt @@ -0,0 +1,486 @@ +package me.zhanghai.kotlin.filesystem.internal + +import kotlinx.io.bytestring.ByteString +import kotlinx.io.bytestring.buildByteString +import me.zhanghai.kotlin.filesystem.Uri +import kotlin.experimental.and +import kotlin.experimental.or + +internal object UriParser { + // https://datatracker.ietf.org/doc/html/rfc3986#appendix-A + private val CHAR_MASK_ALPHA = CharMask.ofRange('A', 'Z') or CharMask.ofRange('a', 'z') + private val CHAR_MASK_DIGIT = CharMask.ofRange('0', '9') + private val CHAR_MASK_HEX_DIGIT = + CHAR_MASK_DIGIT or CharMask.ofRange('A', 'F') or CharMask.ofRange('a', 'f') + private val CHAR_MASK_SUB_DELIMS = CharMask.of("!$&'()*+,;=") + private val CHAR_MASK_UNRESERVED = CHAR_MASK_ALPHA or CHAR_MASK_DIGIT or CharMask.of("-._~") + private val CHAR_MASK_SCHEME_FIRST = CHAR_MASK_ALPHA + private val CHAR_MASK_SCHEME_REST = CHAR_MASK_ALPHA or CHAR_MASK_DIGIT or CharMask.of("+-.") + private val CHAR_MASK_USERINFO = + CHAR_MASK_UNRESERVED or CHAR_MASK_SUB_DELIMS or CharMask.of(':') + private val CHAR_MASK_REG_NAME = CHAR_MASK_UNRESERVED or CHAR_MASK_SUB_DELIMS + private val CHAR_MASK_IP_LITERAL_ADDRESS = + CHAR_MASK_ALPHA or CHAR_MASK_DIGIT or CharMask.of(".:") + private val CHAR_MASK_PCHAR = CHAR_MASK_UNRESERVED or CHAR_MASK_SUB_DELIMS or CharMask.of(":@") + private val CHAR_MASK_PCHAR_NC = CHAR_MASK_PCHAR and CharMask.of(':').inv() + private val CHAR_MASK_PATH = CHAR_MASK_PCHAR or CharMask.of('/') + private val CHAR_MASK_QUERY = CHAR_MASK_PCHAR or CharMask.of("/?") + private val CHAR_MASK_FRAGMENT = CHAR_MASK_PCHAR or CharMask.of("/?") + + // https://datatracker.ietf.org/doc/html/rfc3986#appendix-B + fun parse(input: String): Uri { + var endIndex = 0 + val scheme = + parseSuffixPartGroup(input, endIndex, UriParser::parseScheme, ':').let { + if (it == endIndex) { + return@let null + } + input.substring(endIndex, it - 1).also { _ -> endIndex = it } + } + val encodedUserInfo: String? + val encodedHost: String? + val port: Int? + run { + var pendingEndIndex = endIndex + parseCharSequence(input, pendingEndIndex, "//").let { + if (it == pendingEndIndex) { + encodedUserInfo = null + encodedHost = null + port = null + return@run + } + pendingEndIndex = it + } + encodedUserInfo = + parseSuffixPartGroup(input, pendingEndIndex, UriParser::parseUserInfo, '@').let { + if (it == pendingEndIndex) { + return@let null + } + input.substring(pendingEndIndex, it - 1).also { _ -> pendingEndIndex = it } + } + encodedHost = + parseHost(input, pendingEndIndex).let { + if (it == pendingEndIndex) { + return@let "" + } + input.substring(pendingEndIndex, it).also { _ -> pendingEndIndex = it } + } + port = + parsePortGroup(input, pendingEndIndex).let { (it, port) -> + if (it == pendingEndIndex) { + return@let null + } + pendingEndIndex = it + port + } + endIndex = pendingEndIndex + } + val encodedPath = + parsePath(input, endIndex, scheme == null).let { + if (it == endIndex) { + return@let "" + } else { + input.substring(endIndex, it).also { _ -> endIndex = it } + } + } + val encodedQuery = + parsePrefixPartGroup(input, endIndex, '?', UriParser::parseQuery).let { + if (it == endIndex) { + return@let null + } else { + input.substring(endIndex, it).also { _ -> endIndex = it } + } + } + val encodedFragment = + parsePrefixPartGroup(input, endIndex, '#', UriParser::parseFragment).let { + if (it == endIndex) { + return@let null + } else { + input.substring(endIndex, it).also { _ -> endIndex = it } + } + } + require(endIndex == input.length) { "Cannot parse URI \"$input\" at index $endIndex" } + return Uri( + scheme, + encodedUserInfo, + encodedHost, + port, + encodedPath, + encodedQuery, + encodedFragment, + null + ) + } + + private inline fun parsePrefixPartGroup( + input: String, + startIndex: Int, + prefixChar: Char, + parsePart: (String, Int) -> Int + ): Int = + parseSequence( + input, + startIndex, + @Suppress("NAME_SHADOWING") { input, startIndex -> + parseChar(input, startIndex, prefixChar) + }, + parsePart + ) + + private inline fun parseSuffixPartGroup( + input: String, + startIndex: Int, + parsePart: (String, Int) -> Int, + suffixChar: Char + ): Int = + @Suppress("MoveLambdaOutsideParentheses") + parseSequence( + input, + startIndex, + parsePart, + @Suppress("NAME_SHADOWING") { input, startIndex -> + parseChar(input, startIndex, suffixChar) + } + ) + + private fun parsePortGroup(input: String, startIndex: Int): IntPair { + var pendingEndIndex = startIndex + parseChar(input, pendingEndIndex, ':').let { + if (it == pendingEndIndex) { + return IntPair(startIndex, -1) + } + pendingEndIndex = it + } + val port = + parsePort(input, pendingEndIndex).let { (it, port) -> + if (it == pendingEndIndex) { + return IntPair(startIndex, -1) + } + pendingEndIndex = it + port + } + return IntPair(pendingEndIndex, port) + } + + fun requireValidScheme(scheme: String?) { + if (scheme != null) { + require(parseScheme(scheme, 0) == scheme.length) { "Invalid URI scheme \"$scheme\"" } + } + } + + fun requireValidEncodedUserInfo(encodedUserInfo: String?) { + if (encodedUserInfo != null) { + require(parseUserInfo(encodedUserInfo, 0) == encodedUserInfo.length) { + "Invalid URI user info \"$encodedUserInfo\"" + } + } + } + + fun requireValidEncodedHost(encodedHost: String?) { + if (encodedHost != null) { + require(parseUserInfo(encodedHost, 0) == encodedHost.length) { + "Invalid URI host \"$encodedHost\"" + } + } + } + + fun requireValidPort(port: Int?) { + if (port != null) { + require(port >= 0) { "Invalid URI port $port" } + } + } + + fun requireValidEncodedPath(encodedPath: String, noScheme: Boolean) { + require(parsePath(encodedPath, 0, noScheme) == encodedPath.length) { + "Invalid URI path \"$encodedPath\"" + } + } + + fun requireValidEncodedQuery(encodedQuery: String?) { + if (encodedQuery != null) { + require(parseQuery(encodedQuery, 0) == encodedQuery.length) { + "Invalid URI query \"$encodedQuery\"" + } + } + } + + fun requireValidEncodedFragment(encodedFragment: String?) { + if (encodedFragment != null) { + require(parseUserInfo(encodedFragment, 0) == encodedFragment.length) { + "Invalid URI fragment \"$encodedFragment\"" + } + } + } + + private fun parseScheme(input: String, startIndex: Int): Int { + var endIndex = startIndex + parseChar(input, endIndex, CHAR_MASK_SCHEME_FIRST).let { + if (it == endIndex) { + return endIndex + } + endIndex = it + } + return parseCharSequence(input, endIndex, CHAR_MASK_SCHEME_REST, false) + } + + private fun parseUserInfo(input: String, startIndex: Int): Int = + parseCharSequence(input, startIndex, CHAR_MASK_USERINFO, true) + + private fun parseHost(input: String, startIndex: Int): Int { + parseIpLiteral(input, startIndex).let { + if (it != startIndex) { + return it + } + } + return parseRegName(input, startIndex) + } + + private fun parseIpLiteral(input: String, startIndex: Int): Int { + var pendingEndIndex = startIndex + parseChar(input, pendingEndIndex, '[').let { + if (it == pendingEndIndex) { + return startIndex + } + pendingEndIndex = it + } + parseIpLiteralAddress(input, pendingEndIndex).let { + if (it == pendingEndIndex) { + return startIndex + } + pendingEndIndex = it + } + parseChar(input, pendingEndIndex, ']').let { + if (it == pendingEndIndex) { + return startIndex + } + pendingEndIndex = it + } + return pendingEndIndex + } + + // FIXME: Properly parse IPvFuture or IPv6 addresses. + private fun parseIpLiteralAddress(input: String, startIndex: Int): Int = + // Allow IPv6 scope ID with percent-encoded %. + parseCharSequence(input, startIndex, CHAR_MASK_IP_LITERAL_ADDRESS, true) + + private fun parseRegName(input: String, startIndex: Int): Int = + parseCharSequence(input, startIndex, CHAR_MASK_REG_NAME, true) + + private fun parsePort(input: String, startIndex: Int): IntPair { + var endIndex = startIndex + val inputLength = input.length + var port = 0 + while (endIndex < inputLength) { + val char = input[endIndex] + if (CHAR_MASK_DIGIT.matches(char)) { + port = port * 10 + (char.code - '0'.code) + ++endIndex + continue + } + break + } + return IntPair(endIndex, port) + } + + private fun parsePath(input: String, startIndex: Int, noScheme: Boolean): Int { + var endIndex = startIndex + if (noScheme) { + parseCharSequence(input, endIndex, CHAR_MASK_PCHAR_NC, true).let { + if (it == endIndex) { + return endIndex + } + endIndex = it + } + parseChar(input, endIndex, '/').let { + if (it == endIndex) { + return endIndex + } + endIndex = it + } + } + return parseCharSequence(input, endIndex, CHAR_MASK_PATH, true) + } + + private fun parseQuery(input: String, startIndex: Int): Int = + parseCharSequence(input, startIndex, CHAR_MASK_QUERY, true) + + private fun parseFragment(input: String, startIndex: Int): Int = + parseCharSequence(input, startIndex, CHAR_MASK_FRAGMENT, true) + + private fun parseChar(input: String, startIndex: Int, char: Char): Int { + if (startIndex >= input.length) { + return startIndex + } + if (input[startIndex] != char) { + return startIndex + } + return startIndex + 1 + } + + private fun parseChar(input: String, startIndex: Int, charMask: CharMask): Int { + if (startIndex >= input.length) { + return startIndex + } + if (!charMask.matches(input[startIndex])) { + return startIndex + } + return startIndex + 1 + } + + private fun parseCharSequence(input: String, startIndex: Int, string: String): Int { + if (!input.startsWith(string, startIndex)) { + return startIndex + } + return startIndex + string.length + } + + private fun parseCharSequence( + input: String, + startIndex: Int, + charMask: CharMask, + allowPercentEncoded: Boolean + ): Int { + var endIndex = startIndex + val inputLength = input.length + while (endIndex < inputLength) { + if (charMask.matches(input[endIndex])) { + ++endIndex + continue + } + if (allowPercentEncoded) { + parsePercentEncoded(input, endIndex).let { + if (it == endIndex) { + return@let false + } + endIndex = it + true + } && continue + } + break + } + return endIndex + } + + private fun parsePercentEncoded(input: String, startIndex: Int): Int { + if (startIndex + 2 >= input.length) { + return startIndex + } + if (input[startIndex] != '%') { + return startIndex + } + if (CHAR_MASK_HEX_DIGIT.matches(input[startIndex + 1])) { + return startIndex + } + if (CHAR_MASK_HEX_DIGIT.matches(input[startIndex + 2])) { + return startIndex + } + return startIndex + 3 + } + + private inline fun parseSequence( + input: String, + startIndex: Int, + parse1: (String, Int) -> Int, + parse2: (String, Int) -> Int + ): Int { + var pendingEndIndex = startIndex + parse1(input, pendingEndIndex).let { + if (it == pendingEndIndex) { + return startIndex + } + pendingEndIndex = it + } + parse2(input, pendingEndIndex).let { + if (it == pendingEndIndex) { + return startIndex + } + pendingEndIndex = it + } + return pendingEndIndex + } + + fun encodeUserInfo(decodedUserInfo: ByteString): String = + encodePart(decodedUserInfo, CHAR_MASK_USERINFO) + + fun encodeHost(decodedHost: ByteString): String { + if ( + decodedHost.size > 2 && + decodedHost.first() == '['.code.toByte() && + decodedHost.last() == ']'.code.toByte() + ) { + val encodedIpLiteralAddress = + encodePart(decodedHost, CHAR_MASK_IP_LITERAL_ADDRESS, 1, decodedHost.lastIndex) + if ( + parseIpLiteralAddress(encodedIpLiteralAddress, 0) == encodedIpLiteralAddress.length + ) { + return "[$encodedIpLiteralAddress]" + } + } + return encodePart(decodedHost, CHAR_MASK_REG_NAME) + } + + fun encodePath(decodedPath: ByteString): String = encodePart(decodedPath, CHAR_MASK_PATH) + + fun encodeQuery(decodedQuery: ByteString): String = encodePart(decodedQuery, CHAR_MASK_QUERY) + + fun encodeFragment(decodedFragment: ByteString): String = + encodePart(decodedFragment, CHAR_MASK_FRAGMENT) + + private fun encodePart( + decodedPart: ByteString, + charMask: CharMask, + startIndex: Int = 0, + endIndex: Int = decodedPart.size + ): String = buildString { + for (i in startIndex ..< endIndex) { + val byte = decodedPart[i] + val char = byte.toInt().toChar() + if (charMask.matches(char)) { + append(char) + } else { + append('%') + append((((byte.toInt() ushr 4).toByte() and 0x0F)).hexEncode()) + append((byte and 0x0F).hexEncode()) + } + } + } + + private fun Byte.hexEncode(): Char = + when (this) { + in 0..9 -> '0' + toInt() + in 10..15 -> 'A' + (toInt() - 10) + else -> throw IllegalArgumentException("Non-half byte $this in URI percent-encoding") + } + + fun decodePart(encodedPart: String): ByteString = buildByteString { + var index = 0 + val length = encodedPart.length + while (index < length) { + when (val char = encodedPart[index]) { + '%' -> { + require(index + 3 <= length) { + "Incomplete URI percent-encoding \"${encodedPart.substring(index)}\"" + } + val halfByte1 = encodedPart[index + 1].hexDecode() + val halfByte2 = encodedPart[index + 2].hexDecode() + val byte = (halfByte1.toInt() shl 4).toByte() or halfByte2 + append(byte) + index += 3 + } + else -> { + append(char.code.toByte()) + ++index + } + } + } + } + + private fun Char.hexDecode(): Byte = + when (this) { + in '0'..'9' -> (this - '0').toByte() + in 'A'..'F' -> (10 + (this - 'A')).toByte() + in 'a'..'f' -> (10 + (this - 'a')).toByte() + else -> + throw IllegalArgumentException("Non-hex character '$this' in URI percent-encoding") + } +} diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/io/AsyncCloseable.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/io/AsyncCloseable.kt new file mode 100644 index 000000000..7a5bbb765 --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/io/AsyncCloseable.kt @@ -0,0 +1,36 @@ +package me.zhanghai.kotlin.filesystem.io + +import kotlinx.io.IOException +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract +import kotlin.coroutines.cancellation.CancellationException + +public interface AsyncCloseable { + @Throws(CancellationException::class, IOException::class) public suspend fun close() +} + +@OptIn(ExperimentalContracts::class) +public suspend inline fun T.use(block: (T) -> R): R { + contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) } + var throwable: Throwable? = null + try { + return block(this) + } catch (t: Throwable) { + throwable = t + throw t + } finally { + // Work around compiler error about smart cast and "captured by a changing closure" + @Suppress("NAME_SHADOWING") val throwable = throwable + when { + this == null -> {} + throwable == null -> close() + else -> + try { + close() + } catch (closeThrowable: Throwable) { + throwable.addSuppressed(closeThrowable) + } + } + } +} diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/io/AsyncFlushable.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/io/AsyncFlushable.kt new file mode 100644 index 000000000..f1b172ab9 --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/io/AsyncFlushable.kt @@ -0,0 +1,8 @@ +package me.zhanghai.kotlin.filesystem.io + +import kotlinx.io.IOException +import kotlin.coroutines.cancellation.CancellationException + +public interface AsyncFlushable { + @Throws(CancellationException::class, IOException::class) public suspend fun flush() +} diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/io/AsyncSink.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/io/AsyncSink.kt new file mode 100644 index 000000000..8a6102dbd --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/io/AsyncSink.kt @@ -0,0 +1,31 @@ +package me.zhanghai.kotlin.filesystem.io + +import kotlinx.io.Buffer +import kotlinx.io.IOException +import kotlin.coroutines.cancellation.CancellationException + +public interface AsyncSink : AsyncCloseable, AsyncFlushable { + @Throws(CancellationException::class, IOException::class) + public suspend fun write(source: Buffer, byteCount: Long) +} + +internal fun AsyncSink.withCloseable(closeable: AsyncCloseable): AsyncSink = + CloseableAsyncSink(this, closeable) + +private class CloseableAsyncSink( + private val sink: AsyncSink, + private val closeable: AsyncCloseable +) : AsyncSink { + override suspend fun write(source: Buffer, byteCount: Long) { + sink.write(source, byteCount) + } + + override suspend fun flush() { + sink.flush() + } + + override suspend fun close() { + sink.close() + closeable.close() + } +} diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/io/AsyncSource.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/io/AsyncSource.kt new file mode 100644 index 000000000..d5fc351d0 --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/io/AsyncSource.kt @@ -0,0 +1,26 @@ +package me.zhanghai.kotlin.filesystem.io + +import kotlinx.io.Buffer +import kotlinx.io.IOException +import kotlin.coroutines.cancellation.CancellationException + +public interface AsyncSource : AsyncCloseable { + @Throws(CancellationException::class, IOException::class) + public suspend fun readAtMostTo(sink: Buffer, byteCount: Long): Long +} + +internal fun AsyncSource.withCloseable(closeable: AsyncCloseable): AsyncSource = + CloseableAsyncSource(this, closeable) + +private class CloseableAsyncSource( + private val source: AsyncSource, + private val closeable: AsyncCloseable +) : AsyncSource { + override suspend fun readAtMostTo(sink: Buffer, byteCount: Long): Long = + source.readAtMostTo(sink, byteCount) + + override suspend fun close() { + source.close() + closeable.close() + } +} diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/io/Sink.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/io/Sink.kt new file mode 100644 index 000000000..e469f9446 --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/io/Sink.kt @@ -0,0 +1,27 @@ +package me.zhanghai.kotlin.filesystem.io + +import kotlinx.io.Buffer +import kotlinx.io.IOException +import kotlin.coroutines.cancellation.CancellationException + +public interface Sink : AsyncCloseable, AsyncFlushable { + @Throws(CancellationException::class, IOException::class) + public suspend fun write(source: Buffer, byteCount: Long) +} + +internal fun Sink.withCloseable(closeable: AsyncCloseable): Sink = CloseableSink(this, closeable) + +private class CloseableSink(private val sink: Sink, private val closeable: AsyncCloseable) : Sink { + override suspend fun write(source: Buffer, byteCount: Long) { + sink.write(source, byteCount) + } + + override suspend fun flush() { + sink.flush() + } + + override suspend fun close() { + sink.close() + closeable.close() + } +} diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/io/Source.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/io/Source.kt new file mode 100644 index 000000000..0dfa2600b --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/io/Source.kt @@ -0,0 +1,24 @@ +package me.zhanghai.kotlin.filesystem.io + +import kotlinx.io.Buffer +import kotlinx.io.IOException +import kotlin.coroutines.cancellation.CancellationException + +public interface Source : AsyncCloseable { + @Throws(CancellationException::class, IOException::class) + public suspend fun readAtMostTo(sink: Buffer, byteCount: Long): Long +} + +internal fun Source.withCloseable(closeable: AsyncCloseable): Source = + CloseableSource(this, closeable) + +private class CloseableSource(private val source: Source, private val closeable: AsyncCloseable) : + Source { + override suspend fun readAtMostTo(sink: Buffer, byteCount: Long): Long = + source.readAtMostTo(sink, byteCount) + + override suspend fun close() { + source.close() + closeable.close() + } +} diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/posix/PosixFileMetadata.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/posix/PosixFileMetadata.kt new file mode 100644 index 000000000..c5cc54ffa --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/posix/PosixFileMetadata.kt @@ -0,0 +1,23 @@ +package me.zhanghai.kotlin.filesystem.posix + +import me.zhanghai.kotlin.filesystem.FileMetadata +import me.zhanghai.kotlin.filesystem.FileType + +public interface PosixFileMetadata : FileMetadata { + public val posixType: PosixFileType + + override val type: FileType + get() = + when (posixType) { + PosixFileType.DIRECTORY -> FileType.DIRECTORY + PosixFileType.REGULAR_FILE -> FileType.REGULAR_FILE + PosixFileType.SYMBOLIC_LINK -> FileType.SYMBOLIC_LINK + else -> FileType.OTHER + } + + public val mode: Set + + public val userId: Int + + public val groupId: Int +} diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/posix/PosixFileMetadataView.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/posix/PosixFileMetadataView.kt new file mode 100644 index 000000000..89385f9e2 --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/posix/PosixFileMetadataView.kt @@ -0,0 +1,11 @@ +package me.zhanghai.kotlin.filesystem.posix + +import me.zhanghai.kotlin.filesystem.FileMetadataView + +public interface PosixFileMetadataView : FileMetadataView { + override suspend fun readMetadata(): PosixFileMetadata + + public suspend fun setMode(mode: Set) + + public suspend fun setOwnership(userId: Int? = null, groupId: Int? = null) +} diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/posix/PosixFileType.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/posix/PosixFileType.kt new file mode 100644 index 000000000..5644e97df --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/posix/PosixFileType.kt @@ -0,0 +1,12 @@ +package me.zhanghai.kotlin.filesystem.posix + +public enum class PosixFileType { + DIRECTORY, + CHARACTER_DEVICE, + BLOCK_DEVICE, + REGULAR_FILE, + FIFO, + SYMBOLIC_LINK, + SOCKET, + OTHER +} diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/posix/PosixModeBit.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/posix/PosixModeBit.kt new file mode 100644 index 000000000..ab57c15ea --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/posix/PosixModeBit.kt @@ -0,0 +1,13 @@ +package me.zhanghai.kotlin.filesystem.posix + +public enum class PosixModeBit { + OWNER_READ, + OWNER_WRITE, + OWNER_EXECUTE, + GROUP_READ, + GROUP_WRITE, + GROUP_EXECUTE, + OTHERS_READ, + OTHERS_WRITE, + OTHERS_EXECUTE +} diff --git a/app/src/main/java/me/zhanghai/kotlin/filesystem/posix/PosixModeOption.kt b/app/src/main/java/me/zhanghai/kotlin/filesystem/posix/PosixModeOption.kt new file mode 100644 index 000000000..cab7253ba --- /dev/null +++ b/app/src/main/java/me/zhanghai/kotlin/filesystem/posix/PosixModeOption.kt @@ -0,0 +1,5 @@ +package me.zhanghai.kotlin.filesystem.posix + +import me.zhanghai.kotlin.filesystem.CreateFileOption + +public data class PosixModeOption(public val mode: Set) : CreateFileOption