diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/saf/RootBasicFileAttributeView.kt b/src/org/kde/kdeconnect/Plugins/SftpPlugin/saf/RootBasicFileAttributeView.kt new file mode 100644 index 00000000..9ce5a874 --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/SftpPlugin/saf/RootBasicFileAttributeView.kt @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2024 ShellWen Chen + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ +package org.kde.kdeconnect.Plugins.SftpPlugin.saf + +import java.io.IOException +import java.nio.file.attribute.BasicFileAttributeView +import java.nio.file.attribute.BasicFileAttributes +import java.nio.file.attribute.FileTime + +object RootBasicFileAttributeView : BasicFileAttributeView { + override fun name(): String = "basic" + + override fun readAttributes(): BasicFileAttributes = RootFileAttributes + + override fun setTimes( + lastModifiedTime: FileTime?, + lastAccessTime: FileTime?, + createTime: FileTime? + ) { + throw IOException("Set times of root directory is not supported") + } +} \ No newline at end of file diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/saf/RootFileAttributes.kt b/src/org/kde/kdeconnect/Plugins/SftpPlugin/saf/RootFileAttributes.kt new file mode 100644 index 00000000..de140c4d --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/SftpPlugin/saf/RootFileAttributes.kt @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: 2024 ShellWen Chen + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ +package org.kde.kdeconnect.Plugins.SftpPlugin.saf + +import java.nio.file.attribute.FileTime +import java.nio.file.attribute.GroupPrincipal +import java.nio.file.attribute.PosixFileAttributes +import java.nio.file.attribute.PosixFilePermission +import java.nio.file.attribute.UserPrincipal + +object RootFileAttributes : PosixFileAttributes { + override fun lastModifiedTime(): FileTime = FileTime.fromMillis(0) + override fun lastAccessTime(): FileTime = FileTime.fromMillis(0) + override fun creationTime(): FileTime = FileTime.fromMillis(0) + override fun size(): Long = 0 + override fun fileKey(): Any? = null + override fun isDirectory(): Boolean = true + override fun isRegularFile(): Boolean = false + override fun isSymbolicLink(): Boolean = false + override fun isOther(): Boolean = false + + override fun owner(): UserPrincipal? = null + + override fun group(): GroupPrincipal? = null + + override fun permissions(): Set = // 660 for SAF + setOf( + PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, + PosixFilePermission.GROUP_READ, PosixFilePermission.GROUP_WRITE, + ) +} \ No newline at end of file diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/saf/RootPosixFileAttributeView.kt b/src/org/kde/kdeconnect/Plugins/SftpPlugin/saf/RootPosixFileAttributeView.kt new file mode 100644 index 00000000..f3be63d7 --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/SftpPlugin/saf/RootPosixFileAttributeView.kt @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: 2024 ShellWen Chen + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ +package org.kde.kdeconnect.Plugins.SftpPlugin.saf + +import java.io.IOException +import java.nio.file.attribute.FileTime +import java.nio.file.attribute.GroupPrincipal +import java.nio.file.attribute.PosixFileAttributeView +import java.nio.file.attribute.PosixFileAttributes +import java.nio.file.attribute.PosixFilePermission +import java.nio.file.attribute.UserPrincipal + +object RootPosixFileAttributeView : PosixFileAttributeView { + override fun name(): String = "posix" + + override fun readAttributes(): PosixFileAttributes = RootFileAttributes + override fun setTimes( + lastModifiedTime: FileTime?, + lastAccessTime: FileTime?, + createTime: FileTime? + ) { + throw IOException("Set times of root directory is not supported") + } + + override fun getOwner(): UserPrincipal? = null + + override fun setOwner(owner: UserPrincipal?) { + throw IOException("Set owner of root directory is not supported") + } + + override fun setPermissions(perms: MutableSet?) { + throw IOException("Set permissions of root directory is not supported") + } + + override fun setGroup(group: GroupPrincipal?) { + throw IOException("Set group of root directory is not supported") + } +} \ No newline at end of file diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/saf/SafFileSystem.kt b/src/org/kde/kdeconnect/Plugins/SftpPlugin/saf/SafFileSystem.kt index 03294caa..441d74e5 100644 --- a/src/org/kde/kdeconnect/Plugins/SftpPlugin/saf/SafFileSystem.kt +++ b/src/org/kde/kdeconnect/Plugins/SftpPlugin/saf/SafFileSystem.kt @@ -1,15 +1,21 @@ +/* + * SPDX-FileCopyrightText: 2024 ShellWen Chen + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ package org.kde.kdeconnect.Plugins.SftpPlugin.saf import android.content.Context +import android.net.Uri import android.util.Log +import androidx.documentfile.provider.DocumentFile import org.apache.sshd.common.file.util.BaseFileSystem import java.nio.file.attribute.UserPrincipalLookupService import java.nio.file.spi.FileSystemProvider class SafFileSystem( fileSystemProvider: FileSystemProvider, - roots: MutableMap, - username: String, + private val roots: MutableMap, private val context: Context ) : BaseFileSystem(fileSystemProvider) { override fun close() { @@ -19,15 +25,52 @@ class SafFileSystem( override fun isOpen(): Boolean = true - override fun supportedFileAttributeViews(): Set = setOf("basic") + override fun supportedFileAttributeViews(): Set = setOf("basic", "posix") override fun getUserPrincipalLookupService(): UserPrincipalLookupService { throw UnsupportedOperationException("SAF does not support user principal lookup") } - override fun create(root: String, names: List): SafPath { + private tailrec fun getDocumentFileFromPath( + docFile: DocumentFile, + names: List + ): DocumentFile? { + if (names.isEmpty()) { + return docFile + } + val nextName = names.first() + val nextNames = names.drop(1) + val nextDocFile = docFile.findFile(nextName) + return if (nextDocFile != null) { + getDocumentFileFromPath(nextDocFile, nextNames) + } else { + null + } + } + + override fun create(root: String?, names: List): SafPath { Log.v(TAG, "create: $root, $names") - return SafPath(this, root, names) + if ((root == "/") && names.isEmpty()) { + return SafPath.newRootPath(this) + } + val dirName = names.getOrNull(0) + if (dirName != null) { + roots.forEach { (k, v) -> + if (k == dirName) { + if (names.size == 1) { + return SafPath(this, v, root, names) + } else { + val docFile = getDocumentFileFromPath( + DocumentFile.fromTreeUri(context, v)!!, + names.drop(1) + ) + return SafPath(this, docFile?.uri, root, names) + } + } + } + } + + return SafPath(this, null, root, names) } companion object { diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/saf/SafFileSystemFactory.kt b/src/org/kde/kdeconnect/Plugins/SftpPlugin/saf/SafFileSystemFactory.kt index 9289c66b..8afaa3f3 100644 --- a/src/org/kde/kdeconnect/Plugins/SftpPlugin/saf/SafFileSystemFactory.kt +++ b/src/org/kde/kdeconnect/Plugins/SftpPlugin/saf/SafFileSystemFactory.kt @@ -7,6 +7,7 @@ package org.kde.kdeconnect.Plugins.SftpPlugin.saf import android.content.Context +import android.net.Uri import android.util.Log import org.apache.sshd.common.file.FileSystemFactory import org.apache.sshd.common.session.SessionContext @@ -15,8 +16,8 @@ import java.nio.file.FileSystem import java.nio.file.Path class SafFileSystemFactory(private val context: Context) : FileSystemFactory { - private val provider = SafFileSystemProvider() - private val roots: MutableMap = HashMap() + private val roots: MutableMap = HashMap() + private val provider = SafFileSystemProvider(context, roots) fun initRoots(storageInfoList: List) { Log.i(TAG, "initRoots: $storageInfoList") @@ -29,9 +30,11 @@ class SafFileSystemFactory(private val context: Context) : FileSystemFactory { // roots[curStorageInfo.displayName] = curStorageInfo.uri.path // } } + curStorageInfo.isContentUri -> { - roots[curStorageInfo.displayName] = curStorageInfo.uri.toString() + roots[curStorageInfo.displayName] = curStorageInfo.uri } + else -> { Log.e(TAG, "Unknown storage URI type: $curStorageInfo") } @@ -40,7 +43,7 @@ class SafFileSystemFactory(private val context: Context) : FileSystemFactory { } override fun createFileSystem(session: SessionContext?): FileSystem { - return SafFileSystem(provider, roots, session!!.username, context) + return SafFileSystem(provider, roots, context) } companion object { diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/saf/SafFileSystemProvider.kt b/src/org/kde/kdeconnect/Plugins/SftpPlugin/saf/SafFileSystemProvider.kt index 624561dd..50880645 100644 --- a/src/org/kde/kdeconnect/Plugins/SftpPlugin/saf/SafFileSystemProvider.kt +++ b/src/org/kde/kdeconnect/Plugins/SftpPlugin/saf/SafFileSystemProvider.kt @@ -1,88 +1,433 @@ +/* + * SPDX-FileCopyrightText: 2024 ShellWen Chen + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ package org.kde.kdeconnect.Plugins.SftpPlugin.saf +import android.content.ContentValues +import android.content.Context +import android.net.Uri +import android.os.Build +import android.os.ParcelFileDescriptor +import android.provider.DocumentsContract +import android.util.Log +import java.io.FileNotFoundException +import java.io.IOException import java.net.URI +import java.nio.channels.FileChannel import java.nio.channels.SeekableByteChannel import java.nio.file.AccessMode import java.nio.file.CopyOption import java.nio.file.DirectoryStream import java.nio.file.FileStore import java.nio.file.FileSystem +import java.nio.file.Files import java.nio.file.LinkOption import java.nio.file.OpenOption import java.nio.file.Path +import java.nio.file.StandardOpenOption +import java.nio.file.attribute.BasicFileAttributeView import java.nio.file.attribute.BasicFileAttributes import java.nio.file.attribute.FileAttribute import java.nio.file.attribute.FileAttributeView +import java.nio.file.attribute.FileTime +import java.nio.file.attribute.GroupPrincipal +import java.nio.file.attribute.PosixFileAttributeView +import java.nio.file.attribute.PosixFileAttributes +import java.nio.file.attribute.PosixFilePermission +import java.nio.file.attribute.UserPrincipal import java.nio.file.spi.FileSystemProvider -class SafFileSystemProvider: FileSystemProvider() { +class SafFileSystemProvider( + private val context: Context, + val roots: MutableMap +) : FileSystemProvider() { override fun getScheme(): String = "saf" override fun newFileSystem(uri: URI, env: MutableMap?): FileSystem { - TODO("Not yet implemented") + // SSHD Core does not use this method, so we can just throw an exception + Log.w(TAG, "newFileSystem($uri) not implemented") + throw NotImplementedError("newFileSystem($uri) not implemented") } override fun getFileSystem(uri: URI): FileSystem { - TODO("Not yet implemented") + // SSHD Core does not use this method, so we can just throw an exception + Log.w(TAG, "getFileSystem($uri) not implemented") + throw NotImplementedError("getFileSystem($uri) not implemented") } override fun getPath(uri: URI): Path { - TODO("Not yet implemented") + // SSHD Core does not use this method, so we can just throw an exception + Log.w(TAG, "getPath($uri) not implemented") + throw NotImplementedError("getPath($uri) not implemented") } + /** + * @see org.apache.sshd.sftp.server.FileHandle.getOpenOptions + */ override fun newByteChannel( path: Path, - options: MutableSet, - vararg attrs: FileAttribute<*> + options: Set, + vararg attrs_: FileAttribute<*> ): SeekableByteChannel { - TODO("Not yet implemented") + return newFileChannel(path, options, *attrs_) + } + + /** + * @see org.apache.sshd.sftp.server.FileHandle.getOpenOptions + */ + override fun newFileChannel( + path: Path, + options: Set, + vararg attrs_: FileAttribute<*> + ): FileChannel { + check(path is SafPath) + check(!path.isRoot()) + + /* + * According to https://tools.ietf.org/html/draft-ietf-secsh-filexfer-13#page-33 + * + * The 'attrs' field is ignored if an existing file is opened. + */ + val attrs = if (Files.exists(path)) { + emptyArray() + } else { + attrs_ + } + + when (options) { + // READ + setOf(StandardOpenOption.READ) -> { + val docFile = path.getDocumentFile(context)!! + return ParcelFileDescriptor.AutoCloseInputStream( + context.contentResolver.openFileDescriptor(docFile.uri, "r")!! + ).channel + } + // WRITE, TRUNCATE_EXISTING + // WRITE, CREATE + // WRITE, TRUNCATE_EXISTING, CREATE + // WRITE, CREATE_NEW + setOf( + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING, + ), + setOf( + StandardOpenOption.WRITE, + StandardOpenOption.CREATE, + ), + setOf( + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.CREATE + ), + setOf( + StandardOpenOption.WRITE, + StandardOpenOption.CREATE_NEW, + ) -> { + val docFile = path.getDocumentFile(context) ?: run { + if (options.contains(StandardOpenOption.CREATE) || options.contains(StandardOpenOption.CREATE_NEW)) { + val parent = path.parent.getDocumentFile(context)!! + parent.createFile(Files.probeContentType(path), path.names.last()).apply { + if (this == null) { + throw IOException("Failed to create $path") + } + path.safUri = uri + } + } else { + throw IOException("File not found: $path") + } + } ?: throw IOException("Failed to create $path") + check(docFile.exists()) + return ParcelFileDescriptor.AutoCloseOutputStream( + context.contentResolver.openFileDescriptor(docFile.uri, "rw")!! + ).channel + } + // TODO: Implement other options + } + Log.w(TAG, "newFileChannel($path, $options, $attrs) not implemented") + throw IOException("newFileChannel($path, $options, $attrs) not implemented") } override fun newDirectoryStream( dir: Path, filter: DirectoryStream.Filter ): DirectoryStream { - TODO("Not yet implemented") + check(dir is SafPath) + + if (dir.isRoot()) { + return object : DirectoryStream { + override fun iterator(): MutableIterator { + return roots.mapNotNull { (name, uri) -> + val newPath = SafPath(dir.fileSystem, uri, null, listOf(name)) + if (filter.accept(newPath)) newPath else null + }.toMutableList().iterator() + } + + override fun close() { + // no-op + } + } + } + + check(dir.names.isNotEmpty()) + + return object : DirectoryStream { + override fun iterator(): MutableIterator { + val documentFile = dir.getDocumentFile(context)!! + return documentFile.listFiles().mapNotNull { + val newPath = SafPath(dir.fileSystem, it.uri, null, dir.names + it.name!!) + if (filter.accept(newPath)) newPath else null + }.toMutableList().iterator() + } + + override fun close() { + // no-op + } + } } override fun createDirectory(dir: Path, vararg attrs: FileAttribute<*>) { - TODO("Not yet implemented") + check(dir is SafPath) + if (dir.isRoot()) { + throw IOException("Cannot create root directory") + } + if (dir.parent == null) { + throw IOException("Parent directory does not exist") + } + val parent = dir.parent.getDocumentFile(context) + ?: throw IOException("Parent directory does not exist") + parent.createDirectory(dir.names.last()) } override fun delete(path: Path) { - TODO("Not yet implemented") + check(path is SafPath) + val docFile = path.getDocumentFile(context) + ?: throw java.nio.file.NoSuchFileException( + path.toString(), + ) // No kotlin.NoSuchFileException, they are different + if (!docFile.delete()) { + throw IOException("Failed to delete $path") + } } override fun copy(source: Path, target: Path, vararg options: CopyOption) { - TODO("Not yet implemented") + check(source is SafPath) + check(target is SafPath) + + val sourceDocFile = source.getDocumentFile(context) + ?: throw java.nio.file.NoSuchFileException( + source.toString(), + ) // No kotlin.NoSuchFileException, they are different + + val targetDocFile = target.parent.getDocumentFile(context)!! + .createFile(Files.probeContentType(source), target.names.last()) + ?: throw IOException("Failed to create $target") + + context.contentResolver.openOutputStream(targetDocFile.uri)?.use { os -> + context.contentResolver.openInputStream(sourceDocFile.uri)?.use { is_ -> + is_.copyTo(os) + } + } } override fun move(source: Path, target: Path, vararg options: CopyOption) { - TODO("Not yet implemented") + check(source is SafPath) + check(target is SafPath) + + val sourceUri = source.getDocumentFile(context)!!.uri + val parentUri = source.parent.getDocumentFile(context)!!.uri + val destParentUri = target.parent.getDocumentFile(context)!!.uri + + // 1. If dest parent is the same as source parent, rename the file + if (parentUri == destParentUri) { + try { + val newUri = DocumentsContract.renameDocument( + context.contentResolver, + sourceUri, + target.names.last() + ) + source.safUri = newUri!! + return + } catch (ignored: FileNotFoundException) { + // no-op: fallback to the next method + } + } + + val sourceTreeDocumentId = DocumentsContract.getTreeDocumentId(parentUri) + val destTreeDocumentId = DocumentsContract.getTreeDocumentId(destParentUri) + + // 2. If source and dest are in the same tree, and the API level is high enough, move the file + if (sourceTreeDocumentId == destTreeDocumentId && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.N + ) { + val newUri = DocumentsContract.moveDocument( + context.contentResolver, + sourceUri, + parentUri, + destParentUri + ) + source.safUri = newUri!! + return + } + + // 3. Else copy and delete the file + copy(source, target, *options) + delete(source) } - override fun isSameFile(path: Path, path2: Path): Boolean { - TODO("Not yet implemented") + override fun isSameFile(p1: Path, p2: Path): Boolean { + check(p1 is SafPath) + check(p2 is SafPath) + return p1.root == p2.root && p1.names == p2.names && + p1.getDocumentFile(context)!!.uri == p2.getDocumentFile(context)!!.uri } override fun isHidden(path: Path): Boolean { - TODO("Not yet implemented") + check(path is SafPath) + + if (path.isRoot()) { + return false + } + + return path.names.last().startsWith(".") } - override fun getFileStore(path: Path): FileStore { - TODO("Not yet implemented") + override fun getFileStore(path: Path): FileStore? { + // SAF does not support file store + Log.i(TAG, "getFileStore($path) not implemented") + return null } override fun checkAccess(path: Path, vararg modes: AccessMode) { - TODO("Not yet implemented") + check(path is SafPath) + if (path.isRoot()) { + modes.forEach { + when (it) { + AccessMode.READ -> { + // Root is always readable + } + + AccessMode.WRITE -> { + // Root is not writable + throw java.nio.file.AccessDeniedException("/") // No kotlin.AccessDeniedException, they are different + } + + AccessMode.EXECUTE -> { + // Root is not executable + throw java.nio.file.AccessDeniedException("/") // No kotlin.AccessDeniedException, they are different + } + } + } + return + } + val docFile = path.getDocumentFile(context) + ?: throw java.nio.file.NoSuchFileException( + path.toString(), + ) // No kotlin.NoSuchFileException, they are different + if (!docFile.exists()) { + throw java.nio.file.NoSuchFileException( + docFile.uri.toString(), + ) // No kotlin.NoSuchFileException, they are different + } + modes.forEach { + when (it) { + AccessMode.READ -> { + if (!docFile.canRead()) { + throw java.nio.file.AccessDeniedException(docFile.uri.toString()) // No kotlin.AccessDeniedException, they are different + } + } + + AccessMode.WRITE -> { + if (!docFile.canWrite()) { + throw java.nio.file.AccessDeniedException(docFile.uri.toString()) // No kotlin.AccessDeniedException, they are different + } + } + + AccessMode.EXECUTE -> { + // SAF files is not executable + throw java.nio.file.AccessDeniedException(docFile.uri.toString()) // No kotlin.AccessDeniedException, they are different + } + } + } } override fun getFileAttributeView( path: Path, type: Class, vararg options: LinkOption? - ): V { - TODO("Not yet implemented") + ): V? { + check(path is SafPath) + if (path.isRoot()) { + if (type == BasicFileAttributeView::class.java) { + @Suppress("UNCHECKED_CAST") + return RootBasicFileAttributeView as V + } + if (type == PosixFileAttributeView::class.java) { + @Suppress("UNCHECKED_CAST") + return RootPosixFileAttributeView as V + } + } + + if (type == BasicFileAttributeView::class.java) { + @Suppress("UNCHECKED_CAST") + return object : BasicFileAttributeView { + override fun name(): String = "basic" + + override fun readAttributes(): BasicFileAttributes = + readAttributes(path, BasicFileAttributes::class.java) + + override fun setTimes( + lastModifiedTime: FileTime?, + lastAccessTime: FileTime?, + createTime: FileTime? + ) { + Log.w( + TAG, + "setTimes($path, $lastModifiedTime, $lastAccessTime, $createTime) not implemented" + ) + } + } as V + } + if (type == PosixFileAttributeView::class.java) { + @Suppress("UNCHECKED_CAST") + return object : PosixFileAttributeView { + override fun name(): String = "posix" + + override fun readAttributes(): PosixFileAttributes = + readAttributes(path, PosixFileAttributes::class.java) + + override fun setTimes( + lastModifiedTime: FileTime?, + lastAccessTime: FileTime?, + createTime: FileTime? + ) { + Log.w( + TAG, + "setTimes($path, $lastModifiedTime, $lastAccessTime, $createTime) not implemented" + ) + } + + override fun getOwner(): UserPrincipal? { + Log.i(TAG, "getOwner($path) not implemented") + return null + } + + override fun setOwner(owner: UserPrincipal?) { + Log.i(TAG, "setOwner($path, $owner) not implemented") + } + + override fun setPermissions(perms: MutableSet?) { + Log.i(TAG, "setPermissions($path, $perms) not implemented") + } + + override fun setGroup(group: GroupPrincipal?) { + Log.i(TAG, "setGroup($path, $group) not implemented") + } + } as V + } + Log.w(TAG, "getFileAttributeView($path)[${type.getSimpleName()}] not implemented") + return null } override fun readAttributes( @@ -90,16 +435,110 @@ class SafFileSystemProvider: FileSystemProvider() { type: Class, vararg options: LinkOption? ): A { - // TODO - throw UnsupportedOperationException("readAttributes($path)[${type.getSimpleName()}] N/A"); + check(path is SafPath) + + if (path.isRoot()) { + if (type == BasicFileAttributes::class.java || type == PosixFileAttributes::class.java) { + @Suppress("UNCHECKED_CAST") + return RootFileAttributes as A + } + } + + path.getDocumentFile(context).let { + if (it == null) { + throw java.nio.file.NoSuchFileException( + path.toString(), + ) // No kotlin.NoSuchFileException, they are different + } + if (type == BasicFileAttributes::class.java || type == PosixFileAttributes::class.java) { + @Suppress("UNCHECKED_CAST") + return object : PosixFileAttributes { + override fun lastModifiedTime(): FileTime = + FileTime.fromMillis(it.lastModified()) + + override fun lastAccessTime(): FileTime = FileTime.fromMillis(it.lastModified()) + override fun creationTime(): FileTime = FileTime.fromMillis(it.lastModified()) + override fun size(): Long = it.length() + override fun fileKey(): Any? = null + override fun isDirectory(): Boolean = it.isDirectory + override fun isRegularFile(): Boolean = it.isFile + override fun isSymbolicLink(): Boolean = false + override fun isOther(): Boolean = false + + override fun owner(): UserPrincipal? = null + override fun group(): GroupPrincipal? = null + override fun permissions(): Set = // 660 for SAF + setOf( + PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, + PosixFilePermission.GROUP_READ, PosixFilePermission.GROUP_WRITE, + ) + } as A + } + } + + Log.w(TAG, "readAttributes($path)[${type.getSimpleName()}] not implemented") + throw UnsupportedOperationException("readAttributes($path)[${type.getSimpleName()}] N/A") } override fun readAttributes( path: Path, attributes: String, vararg options: LinkOption? - ): MutableMap { - TODO("Not yet implemented") + ): Map { + // TODO: Implement readAttributes + check(path is SafPath) + if (path.isRoot()) { + if (attributes == "basic" || attributes.startsWith("basic:")) { + return mapOf( + "isDirectory" to true, + "isRegularFile" to false, + "isSymbolicLink" to false, + "isOther" to false, + "size" to 0L, + "fileKey" to null, + "lastModifiedTime" to FileTime.fromMillis(0), + "lastAccessTime" to FileTime.fromMillis(0), + "creationTime" to FileTime.fromMillis(0) + ) + } + if (attributes == "posix" || attributes.startsWith("posix:")) { + return mapOf( + "owner" to null, + "group" to null, + "permissions" to setOf( + PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, + PosixFilePermission.GROUP_READ, PosixFilePermission.GROUP_WRITE, + ) + ) + } + } + val documentFile = path.getDocumentFile(context) + check(documentFile != null) + if (attributes == "basic" || attributes.startsWith("basic:")) { + return mapOf( + "isDirectory" to documentFile.isDirectory, + "isRegularFile" to documentFile.isFile, + "isSymbolicLink" to false, + "isOther" to false, + "size" to documentFile.length(), + "fileKey" to null, + "lastModifiedTime" to FileTime.fromMillis(documentFile.lastModified()), + "lastAccessTime" to FileTime.fromMillis(documentFile.lastModified()), + "creationTime" to FileTime.fromMillis(documentFile.lastModified()) + ) + } + if (attributes == "posix" || attributes.startsWith("posix:")) { + return mapOf( + "owner" to null, + "group" to null, + "permissions" to setOf( + PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, + PosixFilePermission.GROUP_READ, PosixFilePermission.GROUP_WRITE, + ) + ) + } + Log.w(TAG, "readAttributes($path, $attributes) not implemented") + throw UnsupportedOperationException("readAttributes($path, $attributes) N/A") } override fun setAttribute( @@ -108,6 +547,40 @@ class SafFileSystemProvider: FileSystemProvider() { value: Any?, vararg options: LinkOption? ) { - TODO("Not yet implemented") + check(path is SafPath) + when (attribute) { + "basic:lastModifiedTime" -> { + check(value is FileTime) + path.getDocumentFile(context)?.let { + val updateValues = ContentValues().apply { + put(DocumentsContract.Document.COLUMN_LAST_MODIFIED, value.toMillis()) + } + if (context.contentResolver.update(it.uri, updateValues, null, null) == 0) { + throw IOException("Failed to set lastModifiedTime of $path") + } + } ?: throw java.nio.file.NoSuchFileException( + path.toString(), + ) // No kotlin.NoSuchFileException, they are different + } + + "basic:lastAccessTime", "basic:creationTime" -> { + check(value is FileTime) + throw UnsupportedOperationException("$attribute is read-only") + } + + "posix:owner", "posix:group", "posix:permissions" -> { + Log.w(TAG, "set posix attribute $attribute not implemented") + } + + else -> { + Log.w(TAG, "setAttribute($path, $attribute, $value) not implemented") + } + } + // TODO: Implement setAttribute + Log.w(TAG, "setAttribute($path, $attribute, $value) not implemented") + } + + companion object { + private const val TAG = "SafFileSystemProvider" } } \ No newline at end of file diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/saf/SafPath.kt b/src/org/kde/kdeconnect/Plugins/SftpPlugin/saf/SafPath.kt index 651ed6ef..317f638f 100644 --- a/src/org/kde/kdeconnect/Plugins/SftpPlugin/saf/SafPath.kt +++ b/src/org/kde/kdeconnect/Plugins/SftpPlugin/saf/SafPath.kt @@ -1,14 +1,38 @@ +/* + * SPDX-FileCopyrightText: 2024 ShellWen Chen + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ package org.kde.kdeconnect.Plugins.SftpPlugin.saf +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.documentfile.provider.DocumentFile import org.apache.sshd.common.file.util.BasePath import java.nio.file.LinkOption -import java.nio.file.Path class SafPath( fileSystem: SafFileSystem, - root: String, names: List + var safUri: Uri?, + val root: String?, val names: List ) : BasePath(fileSystem, root, names) { - override fun toRealPath(vararg options: LinkOption?): Path { - return this // FIXME + override fun toRealPath(vararg options: LinkOption?): SafPath { + return this.normalize() } -} \ No newline at end of file + + fun getDocumentFile(ctx: Context): DocumentFile? { + if (safUri == null) return null + return DocumentFile.fromTreeUri(ctx, safUri!!) + } + + fun isRoot(): Boolean { + return (root == "/") && names.isEmpty() + } + + companion object { + fun newRootPath(fileSystem: SafFileSystem): SafPath { + return SafPath(fileSystem, null, "/", emptyList()) + } + } +}