2
0
mirror of https://github.com/KDE/kdeconnect-android synced 2025-08-31 06:05:12 +00:00

feat: add SAF support

This commit is contained in:
ShellWen Chen
2024-06-15 18:10:43 +08:00
committed by Albert Vaca Cintora
parent 7fbfc9df90
commit 0fb6e25682
7 changed files with 682 additions and 39 deletions

View File

@@ -0,0 +1,25 @@
/*
* SPDX-FileCopyrightText: 2024 ShellWen Chen <me@shellwen.com>
*
* 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")
}
}

View File

@@ -0,0 +1,34 @@
/*
* SPDX-FileCopyrightText: 2024 ShellWen Chen <me@shellwen.com>
*
* 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<PosixFilePermission> = // 660 for SAF
setOf(
PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE,
PosixFilePermission.GROUP_READ, PosixFilePermission.GROUP_WRITE,
)
}

View File

@@ -0,0 +1,41 @@
/*
* SPDX-FileCopyrightText: 2024 ShellWen Chen <me@shellwen.com>
*
* 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<PosixFilePermission>?) {
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")
}
}

View File

@@ -1,15 +1,21 @@
/*
* SPDX-FileCopyrightText: 2024 ShellWen Chen <me@shellwen.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin.saf package org.kde.kdeconnect.Plugins.SftpPlugin.saf
import android.content.Context import android.content.Context
import android.net.Uri
import android.util.Log import android.util.Log
import androidx.documentfile.provider.DocumentFile
import org.apache.sshd.common.file.util.BaseFileSystem import org.apache.sshd.common.file.util.BaseFileSystem
import java.nio.file.attribute.UserPrincipalLookupService import java.nio.file.attribute.UserPrincipalLookupService
import java.nio.file.spi.FileSystemProvider import java.nio.file.spi.FileSystemProvider
class SafFileSystem( class SafFileSystem(
fileSystemProvider: FileSystemProvider, fileSystemProvider: FileSystemProvider,
roots: MutableMap<String, String?>, private val roots: MutableMap<String, Uri>,
username: String,
private val context: Context private val context: Context
) : BaseFileSystem<SafPath>(fileSystemProvider) { ) : BaseFileSystem<SafPath>(fileSystemProvider) {
override fun close() { override fun close() {
@@ -19,15 +25,52 @@ class SafFileSystem(
override fun isOpen(): Boolean = true override fun isOpen(): Boolean = true
override fun supportedFileAttributeViews(): Set<String> = setOf("basic") override fun supportedFileAttributeViews(): Set<String> = setOf("basic", "posix")
override fun getUserPrincipalLookupService(): UserPrincipalLookupService { override fun getUserPrincipalLookupService(): UserPrincipalLookupService {
throw UnsupportedOperationException("SAF does not support user principal lookup") throw UnsupportedOperationException("SAF does not support user principal lookup")
} }
override fun create(root: String, names: List<String>): SafPath { private tailrec fun getDocumentFileFromPath(
docFile: DocumentFile,
names: List<String>
): 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<String>): SafPath {
Log.v(TAG, "create: $root, $names") 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 { companion object {

View File

@@ -7,6 +7,7 @@
package org.kde.kdeconnect.Plugins.SftpPlugin.saf package org.kde.kdeconnect.Plugins.SftpPlugin.saf
import android.content.Context import android.content.Context
import android.net.Uri
import android.util.Log import android.util.Log
import org.apache.sshd.common.file.FileSystemFactory import org.apache.sshd.common.file.FileSystemFactory
import org.apache.sshd.common.session.SessionContext import org.apache.sshd.common.session.SessionContext
@@ -15,8 +16,8 @@ import java.nio.file.FileSystem
import java.nio.file.Path import java.nio.file.Path
class SafFileSystemFactory(private val context: Context) : FileSystemFactory { class SafFileSystemFactory(private val context: Context) : FileSystemFactory {
private val provider = SafFileSystemProvider() private val roots: MutableMap<String, Uri> = HashMap()
private val roots: MutableMap<String, String?> = HashMap() private val provider = SafFileSystemProvider(context, roots)
fun initRoots(storageInfoList: List<SftpPlugin.StorageInfo>) { fun initRoots(storageInfoList: List<SftpPlugin.StorageInfo>) {
Log.i(TAG, "initRoots: $storageInfoList") Log.i(TAG, "initRoots: $storageInfoList")
@@ -29,9 +30,11 @@ class SafFileSystemFactory(private val context: Context) : FileSystemFactory {
// roots[curStorageInfo.displayName] = curStorageInfo.uri.path // roots[curStorageInfo.displayName] = curStorageInfo.uri.path
// } // }
} }
curStorageInfo.isContentUri -> { curStorageInfo.isContentUri -> {
roots[curStorageInfo.displayName] = curStorageInfo.uri.toString() roots[curStorageInfo.displayName] = curStorageInfo.uri
} }
else -> { else -> {
Log.e(TAG, "Unknown storage URI type: $curStorageInfo") 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 { override fun createFileSystem(session: SessionContext?): FileSystem {
return SafFileSystem(provider, roots, session!!.username, context) return SafFileSystem(provider, roots, context)
} }
companion object { companion object {

View File

@@ -1,88 +1,433 @@
/*
* SPDX-FileCopyrightText: 2024 ShellWen Chen <me@shellwen.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin.saf 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.net.URI
import java.nio.channels.FileChannel
import java.nio.channels.SeekableByteChannel import java.nio.channels.SeekableByteChannel
import java.nio.file.AccessMode import java.nio.file.AccessMode
import java.nio.file.CopyOption import java.nio.file.CopyOption
import java.nio.file.DirectoryStream import java.nio.file.DirectoryStream
import java.nio.file.FileStore import java.nio.file.FileStore
import java.nio.file.FileSystem import java.nio.file.FileSystem
import java.nio.file.Files
import java.nio.file.LinkOption import java.nio.file.LinkOption
import java.nio.file.OpenOption import java.nio.file.OpenOption
import java.nio.file.Path 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.BasicFileAttributes
import java.nio.file.attribute.FileAttribute import java.nio.file.attribute.FileAttribute
import java.nio.file.attribute.FileAttributeView 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 import java.nio.file.spi.FileSystemProvider
class SafFileSystemProvider: FileSystemProvider() { class SafFileSystemProvider(
private val context: Context,
val roots: MutableMap<String, Uri>
) : FileSystemProvider() {
override fun getScheme(): String = "saf" override fun getScheme(): String = "saf"
override fun newFileSystem(uri: URI, env: MutableMap<String, *>?): FileSystem { override fun newFileSystem(uri: URI, env: MutableMap<String, *>?): 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 { 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 { 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( override fun newByteChannel(
path: Path, path: Path,
options: MutableSet<out OpenOption>, options: Set<OpenOption>,
vararg attrs: FileAttribute<*> vararg attrs_: FileAttribute<*>
): SeekableByteChannel { ): 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<OpenOption>,
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( override fun newDirectoryStream(
dir: Path, dir: Path,
filter: DirectoryStream.Filter<in Path> filter: DirectoryStream.Filter<in Path>
): DirectoryStream<Path> { ): DirectoryStream<Path> {
TODO("Not yet implemented") check(dir is SafPath)
if (dir.isRoot()) {
return object : DirectoryStream<Path> {
override fun iterator(): MutableIterator<Path> {
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<Path> {
override fun iterator(): MutableIterator<Path> {
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<*>) { 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) { 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) { 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) { 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 { override fun isSameFile(p1: Path, p2: Path): Boolean {
TODO("Not yet implemented") 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 { 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 { override fun getFileStore(path: Path): FileStore? {
TODO("Not yet implemented") // SAF does not support file store
Log.i(TAG, "getFileStore($path) not implemented")
return null
} }
override fun checkAccess(path: Path, vararg modes: AccessMode) { 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 <V : FileAttributeView> getFileAttributeView( override fun <V : FileAttributeView> getFileAttributeView(
path: Path, path: Path,
type: Class<V>, type: Class<V>,
vararg options: LinkOption? vararg options: LinkOption?
): V { ): V? {
TODO("Not yet implemented") 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<PosixFilePermission>?) {
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 <A : BasicFileAttributes> readAttributes( override fun <A : BasicFileAttributes> readAttributes(
@@ -90,16 +435,110 @@ class SafFileSystemProvider: FileSystemProvider() {
type: Class<A>, type: Class<A>,
vararg options: LinkOption? vararg options: LinkOption?
): A { ): A {
// TODO check(path is SafPath)
throw UnsupportedOperationException("readAttributes($path)[${type.getSimpleName()}] N/A");
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<PosixFilePermission> = // 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( override fun readAttributes(
path: Path, path: Path,
attributes: String, attributes: String,
vararg options: LinkOption? vararg options: LinkOption?
): MutableMap<String, Any?> { ): Map<String, Any?> {
TODO("Not yet implemented") // 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( override fun setAttribute(
@@ -108,6 +547,40 @@ class SafFileSystemProvider: FileSystemProvider() {
value: Any?, value: Any?,
vararg options: LinkOption? 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"
} }
} }

View File

@@ -1,14 +1,38 @@
/*
* SPDX-FileCopyrightText: 2024 ShellWen Chen <me@shellwen.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin.saf 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 org.apache.sshd.common.file.util.BasePath
import java.nio.file.LinkOption import java.nio.file.LinkOption
import java.nio.file.Path
class SafPath( class SafPath(
fileSystem: SafFileSystem, fileSystem: SafFileSystem,
root: String, names: List<String> var safUri: Uri?,
val root: String?, val names: List<String>
) : BasePath<SafPath, SafFileSystem>(fileSystem, root, names) { ) : BasePath<SafPath, SafFileSystem>(fileSystem, root, names) {
override fun toRealPath(vararg options: LinkOption?): Path { override fun toRealPath(vararg options: LinkOption?): SafPath {
return this // FIXME return this.normalize()
}
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())
}
} }
} }