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

refactor: migrate SSHD Core to 1.0.0. SAF is unavailable now.

This commit is contained in:
ShellWen Chen
2024-06-13 22:01:58 +08:00
committed by Albert Vaca Cintora
parent 6783f0a167
commit e37a519e3a
17 changed files with 297 additions and 1012 deletions

View File

@@ -132,7 +132,7 @@ android {
}
dependencies {
coreLibraryDesugaring(libs.android.desugarJdkLibs)
coreLibraryDesugaring(libs.android.desugarJdkLibsNio)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui.tooling.preview)

View File

@@ -32,13 +32,13 @@ reactiveStreams = "1.0.4"
recyclerview = "1.3.2"
rxjava = "2.2.21"
sl4j = "2.0.4"
sshdCore = "0.14.0"
sshdCore = "1.0.0"
swiperefreshlayout = "1.1.0"
uiToolingPreview = "1.6.7"
univocityParsers = "2.9.1"
[libraries]
android-desugarJdkLibs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "androidDesugarJdkLibs" }
android-desugarJdkLibsNio = { module = "com.android.tools:desugar_jdk_libs_nio", version.ref = "androidDesugarJdkLibs" }
android-smsmms = { module = "org.kde.invent.sredman:android-smsmms", version.ref = "androidSmsmms" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }

View File

@@ -1,44 +0,0 @@
/*
* SPDX-FileCopyrightText: 2018 Erik Duisters <e.duisters1@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin;
import android.content.Context;
import org.apache.sshd.common.Session;
import org.apache.sshd.common.file.FileSystemFactory;
import org.apache.sshd.common.file.FileSystemView;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
class AndroidFileSystemFactory implements FileSystemFactory {
final private Context context;
final Map<String, String> roots;
AndroidFileSystemFactory(Context context) {
this.context = context;
this.roots = new HashMap<>();
}
void initRoots(List<SftpPlugin.StorageInfo> storageInfoList) {
for (SftpPlugin.StorageInfo curStorageInfo : storageInfoList) {
if (curStorageInfo.isFileUri()) {
if (curStorageInfo.uri.getPath() != null){
roots.put(curStorageInfo.displayName, curStorageInfo.uri.getPath());
}
} else if (curStorageInfo.isContentUri()){
roots.put(curStorageInfo.displayName, curStorageInfo.uri.toString());
}
}
}
@Override
public FileSystemView createFileSystemView(final Session username) {
return new AndroidSafFileSystemView(roots, username.getUsername(), context);
}
}

View File

@@ -1,101 +0,0 @@
/*
* SPDX-FileCopyrightText: 2018 Erik Duisters <e.duisters1@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin;
import android.content.Context;
import org.apache.sshd.common.file.FileSystemView;
import org.apache.sshd.common.file.SshFile;
import org.apache.sshd.common.file.nativefs.NativeFileSystemView;
import org.apache.sshd.common.file.nativefs.NativeSshFile;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
class AndroidFileSystemView extends NativeFileSystemView {
final private String userName;
final private Context context;
private final Map<String, String> roots;
private final RootFile rootFile;
AndroidFileSystemView(Map<String, String> roots, String currentRoot, final String userName, Context context) {
super(userName, roots, currentRoot, File.separatorChar, true);
this.roots = roots;
this.userName = userName;
this.context = context;
this.rootFile = new RootFile( createFileList(), userName, true);
}
private List<SshFile> createFileList() {
List<SshFile> list = new ArrayList<>();
for (Map.Entry<String, String> entry : roots.entrySet()) {
String displayName = entry.getKey();
String path = entry.getValue();
list.add(createNativeSshFile(displayName, new File(path), userName));
}
return list;
}
@Override
public SshFile getFile(String file) {
return getFile("/", file);
}
@Override
public SshFile getFile(SshFile baseDir, String file) {
return getFile(baseDir.getAbsolutePath(), file);
}
@Override
protected SshFile getFile(String dir, String file) {
if (!dir.endsWith("/")) {
dir = dir + "/";
}
if (!file.startsWith("/")) {
file = dir + file;
}
String filename = NativeSshFile.getPhysicalName("/", "/", file, false);
if (filename.equals("/")) {
return rootFile;
}
for (String root : roots.keySet()) {
if (filename.indexOf(root) == 1) {
String nameWithoutRoot = filename.substring(root.length() + 1);
String path = roots.get(root);
if (nameWithoutRoot.isEmpty()) {
return createNativeSshFile(filename, new File(path), userName);
} else {
return createNativeSshFile(filename, new File(path, nameWithoutRoot), userName);
}
}
}
//It's a file under / but not one covered by any Tree
return new RootFile(new ArrayList<>(0), userName, false);
}
// NativeFileSystemView.getFile(), NativeSshFile.getParentFile() and NativeSshFile.listSshFiles() call
// createNativeSshFile to create new NativeSshFiles so override that instead of getFile() to always create an AndroidSshFile
@Override
public AndroidSshFile createNativeSshFile(String name, File file, String username) {
return new AndroidSshFile(this, name, file, username, context);
}
@Override
public FileSystemView getNormalizedView() {
return this;
}
}

View File

@@ -1,124 +0,0 @@
/*
* SPDX-FileCopyrightText: 2018 Erik Duisters <e.duisters1@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin;
import android.content.Context;
import android.net.Uri;
import android.provider.DocumentsContract;
import org.apache.sshd.common.file.FileSystemView;
import org.apache.sshd.common.file.SshFile;
import org.apache.sshd.common.file.nativefs.NativeSshFile;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
public class AndroidSafFileSystemView implements FileSystemView {
final String userName;
final Context context;
private final Map<String, String> roots;
private final RootFile rootFile;
AndroidSafFileSystemView(Map<String, String> roots, String userName, Context context) {
this.roots = roots;
this.userName = userName;
this.context = context;
this.rootFile = new RootFile( createFileList(), userName, true);
}
private List<SshFile> createFileList() {
List<SshFile> list = new ArrayList<>();
for (Map.Entry<String, String> entry : roots.entrySet()) {
String displayName = entry.getKey();
String uri = entry.getValue();
Uri treeUri = Uri.parse(uri);
Uri documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, DocumentsContract.getTreeDocumentId(treeUri));
list.add(createAndroidSafSshFile(null, documentUri, File.separatorChar + displayName));
}
return list;
}
@Override
public SshFile getFile(String file) {
return getFile("/", file);
}
@Override
public SshFile getFile(SshFile baseDir, String file) {
return getFile(baseDir.getAbsolutePath(), file);
}
protected SshFile getFile(String dir, String file) {
if (!dir.endsWith("/")) {
dir = dir + "/";
}
if (!file.startsWith("/")) {
file = dir + file;
}
String filename = NativeSshFile.getPhysicalName("/", "/", file, false);
if (filename.equals("/")) {
return rootFile;
}
for (String root : roots.keySet()) {
if (filename.indexOf(root) == 1) {
String nameWithoutRoot = filename.substring(root.length() + 1);
String pathOrUri = roots.get(root);
Uri treeUri = Uri.parse(pathOrUri);
if (nameWithoutRoot.isEmpty()) {
//TreeDocument
Uri documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, DocumentsContract.getTreeDocumentId(treeUri));
return createAndroidSafSshFile(documentUri, documentUri, filename);
} else {
/*
When sharing a root document tree like "Internal Storage" documentUri looks like:
content://com.android.externalstorage.documents/tree/primary:/document/primary:
For a file or folder beneath that the uri looks like:
content://com.android.externalstorage.documents/tree/primary:/document/primary:Folder/file.txt
Sharing a non root document tree the documentUri looks like:
content://com.android.externalstorage.documents/tree/primary:Download/document/primary:Download
For a file or folder beneath that the uri looks like:
content://com.android.externalstorage.documents/tree/primary:Download/document/primary:Download/Folder/file.txt
*/
String treeDocumentId = DocumentsContract.getTreeDocumentId(treeUri);
File nameWithoutRootFile = new File(nameWithoutRoot);
String parentSuffix = nameWithoutRootFile.getParent();
String parentDocumentId = treeDocumentId + ("/".equals(parentSuffix) ? "" : parentSuffix);
Uri parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, parentDocumentId);
String documentId = treeDocumentId + (treeDocumentId.endsWith(":") ? nameWithoutRoot.substring(1) : nameWithoutRoot);
Uri documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId);
return createAndroidSafSshFile(parentUri, documentUri, filename);
}
}
}
//It's a file under / but not one covered by any Tree
return new RootFile(new ArrayList<>(0), userName, false);
}
public AndroidSafSshFile createAndroidSafSshFile(Uri parentUri, Uri documentUri, String virtualFilename) {
return new AndroidSafSshFile(this, parentUri, documentUri, virtualFilename);
}
@Override
public FileSystemView getNormalizedView() {
return this;
}
}

View File

@@ -1,480 +0,0 @@
/*
* SPDX-FileCopyrightText: 2018 Erik Duisters <e.duisters1@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.provider.DocumentsContract;
import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.Nullable;
import org.apache.sshd.common.file.SshFile;
import org.kde.kdeconnect.Helpers.FilesHelper;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
public class AndroidSafSshFile implements SshFile {
private static final String TAG = AndroidSafSshFile.class.getSimpleName();
private final String virtualFileName;
private DocumentInfo documentInfo;
private Uri parentUri;
private final AndroidSafFileSystemView fileSystemView;
AndroidSafSshFile(final AndroidSafFileSystemView fileSystemView, Uri parentUri, Uri uri, String virtualFileName) {
this.fileSystemView = fileSystemView;
this.parentUri = parentUri;
this.documentInfo = new DocumentInfo(fileSystemView.context, uri);
this.virtualFileName = virtualFileName;
}
@Override
public String getAbsolutePath() {
return virtualFileName;
}
@Override
public String getName() {
/* From NativeSshFile, looks a lot like new File(virtualFileName).getName() to me */
// strip the last '/'
String shortName = virtualFileName;
int filelen = virtualFileName.length();
if (shortName.charAt(filelen - 1) == File.separatorChar) {
shortName = shortName.substring(0, filelen - 1);
}
// return from the last '/'
int slashIndex = shortName.lastIndexOf(File.separatorChar);
if (slashIndex != -1) {
shortName = shortName.substring(slashIndex + 1);
}
return shortName;
}
@Override
public String getOwner() {
return fileSystemView.userName;
}
@Override
public boolean isDirectory() {
return documentInfo.isDirectory;
}
@Override
public boolean isFile() {
return documentInfo.isFile;
}
@Override
public boolean doesExist() {
return documentInfo.exists;
}
@Override
public long getSize() {
return documentInfo.length;
}
@Override
public long getLastModified() {
return documentInfo.lastModified;
}
@Override
public boolean setLastModified(long time) {
//TODO
/* Throws UnsupportedOperationException on API 26
try {
ContentValues updateValues = new ContentValues();
updateValues.put(DocumentsContract.Document.COLUMN_LAST_MODIFIED, time);
result = fileSystemView.context.getContentResolver().update(documentInfo.uri, updateValues, null, null) != 0;
documentInfo.lastModified = time;
} catch (NullPointerException ignored) {}
*/
return true;
}
@Override
public boolean isReadable() {
return documentInfo.canRead;
}
@Override
public boolean isWritable() {
return documentInfo.canWrite;
}
@Override
public boolean isExecutable() {
return documentInfo.isDirectory;
}
@Override
public boolean isRemovable() {
Log.d(TAG, "isRemovable() - is this ever called?");
return false;
}
public SshFile getParentFile() {
Log.d(TAG,"getParentFile() - is this ever called");
return null;
}
@Override
public boolean delete() {
boolean ret;
try {
ret = DocumentsContract.deleteDocument(fileSystemView.context.getContentResolver(), documentInfo.uri);
} catch (FileNotFoundException e) {
ret = false;
}
return ret;
}
@Override
public boolean create() {
return create(parentUri, FilesHelper.getMimeTypeFromFile(virtualFileName), getName());
}
private boolean create(Uri parentUri, String mimeType, String name) {
Uri uri = null;
try {
uri = DocumentsContract.createDocument(fileSystemView.context.getContentResolver(), parentUri, mimeType, name);
if (uri != null) {
documentInfo = new DocumentInfo(fileSystemView.context, uri);
if (!name.equals(documentInfo.displayName)) {
delete();
return false;
}
}
} catch (FileNotFoundException ignored) {}
return uri != null;
}
@Override
public void truncate() {
if (documentInfo.length > 0) {
delete();
create();
}
}
@Override
public boolean move(final SshFile dest) {
boolean success = false;
Uri destParentUri = ((AndroidSafSshFile)dest).parentUri;
if (destParentUri.equals(parentUri)) {
//Rename
try {
Uri newUri = DocumentsContract.renameDocument(fileSystemView.context.getContentResolver(), documentInfo.uri, dest.getName());
if (newUri != null) {
success = true;
documentInfo.uri = newUri;
}
} catch (FileNotFoundException ignored) {}
} else {
// Move:
String sourceTreeDocumentId = DocumentsContract.getTreeDocumentId(parentUri);
String destTreeDocumentId = DocumentsContract.getTreeDocumentId(((AndroidSafSshFile) dest).parentUri);
if (sourceTreeDocumentId.equals(destTreeDocumentId) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
try {
Uri newUri = DocumentsContract.moveDocument(fileSystemView.context.getContentResolver(), documentInfo.uri, parentUri, destParentUri);
if (newUri != null) {
success = true;
parentUri = destParentUri;
documentInfo.uri = newUri;
}
} catch (Exception e) {
Log.e(TAG,"DocumentsContract.moveDocument() threw an exception", e);
}
} else {
try {
if (dest.create()) {
try (InputStream in = createInputStream(0); OutputStream out = dest.createOutputStream(0)) {
byte[] buffer = new byte[10 * 1024];
int read;
while ((read = in.read(buffer)) > 0) {
out.write(buffer, 0, read);
}
out.flush();
delete();
success = true;
} catch (IOException e) {
if (dest.doesExist()) {
dest.delete();
}
}
}
} catch (IOException ignored) {}
}
}
return success;
}
@Override
public boolean mkdir() {
return create(parentUri, DocumentsContract.Document.MIME_TYPE_DIR, getName());
}
@Override
public List<SshFile> listSshFiles() {
if (!documentInfo.isDirectory) {
return null;
}
final ContentResolver resolver = fileSystemView.context.getContentResolver();
final Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(documentInfo.uri, DocumentsContract.getDocumentId(documentInfo.uri));
final ArrayList<AndroidSafSshFile> results = new ArrayList<>();
Cursor c = resolver.query(childrenUri, new String[]
{ DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME }, null, null, null);
while (c != null && c.moveToNext()) {
final String documentId = c.getString(c.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID));
final String displayName = c.getString(c.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME));
final Uri documentUri = DocumentsContract.buildDocumentUriUsingTree(documentInfo.uri, documentId);
results.add(new AndroidSafSshFile(fileSystemView, parentUri, documentUri, virtualFileName + File.separatorChar + displayName));
}
if (c != null) {
c.close();
}
return Collections.unmodifiableList(results);
}
@Override
public OutputStream createOutputStream(final long offset) throws IOException {
if (offset != 0) {
throw new IOException("Seeking is not supported.");
}
return fileSystemView.context.getContentResolver().openOutputStream(documentInfo.uri);
}
@Override
public InputStream createInputStream(final long offset) throws IOException {
InputStream s = fileSystemView.context.getContentResolver().openInputStream(documentInfo.uri);
final long sought = s.skip(offset);
if (sought != offset) {
throw new IOException(String.format("Unable to seek %d bytes, sought %d bytes.", offset, sought));
}
return s;
}
@Override
public void handleClose() {
// Nop
}
@Override
public Map<Attribute, Object> getAttributes(boolean followLinks) {
Map<SshFile.Attribute, Object> attributes = new HashMap<>();
for (SshFile.Attribute attr : SshFile.Attribute.values()) {
switch (attr) {
case Uid:
case Gid:
case NLink:
continue;
}
attributes.put(attr, getAttribute(attr, followLinks));
}
return attributes;
}
@Override
public Object getAttribute(Attribute attribute, boolean followLinks) {
Object ret;
switch (attribute) {
case Size:
ret = documentInfo.length;
break;
case Uid:
case Gid:
ret = 1;
break;
case Owner:
case Group:
ret = getOwner();
break;
case IsDirectory:
ret = documentInfo.isDirectory;
break;
case IsRegularFile:
ret = documentInfo.isFile;
break;
case IsSymbolicLink:
ret = false;
break;
case Permissions:
Set<Permission> tmp = new HashSet<>();
if (documentInfo.canRead) {
tmp.add(SshFile.Permission.UserRead);
tmp.add(SshFile.Permission.GroupRead);
tmp.add(SshFile.Permission.OthersRead);
}
if (documentInfo.canWrite) {
tmp.add(SshFile.Permission.UserWrite);
tmp.add(SshFile.Permission.GroupWrite);
tmp.add(SshFile.Permission.OthersWrite);
}
if (isExecutable()) {
tmp.add(SshFile.Permission.UserExecute);
tmp.add(SshFile.Permission.GroupExecute);
tmp.add(SshFile.Permission.OthersExecute);
}
ret = tmp.isEmpty()
? EnumSet.noneOf(SshFile.Permission.class)
: EnumSet.copyOf(tmp);
break;
case CreationTime:
case LastModifiedTime:
case LastAccessTime:
ret = documentInfo.lastModified;
break;
case NLink:
ret = 0;
break;
default:
ret = null;
break;
}
return ret;
}
@Override
public void setAttributes(Map<Attribute, Object> attributes) {
//TODO: Using Java 7 NIO it should be possible to implement setting a number of attributes but does SaF allow that?
}
@Override
public void setAttribute(Attribute attribute, Object value) {}
@Override
public String readSymbolicLink() throws IOException {
throw new IOException("Not Implemented");
}
@Override
public void createSymbolicLink(SshFile destination) throws IOException {
throw new IOException("Not Implemented");
}
/**
* Retrieve all file info using 1 query to speed things up
* The only fields guaranteed to be initialized are uri and exists
*/
private static class DocumentInfo {
private Uri uri;
private boolean exists;
private boolean canRead;
private boolean canWrite;
private boolean isDirectory;
private boolean isFile;
private long lastModified;
private long length;
@Nullable
private String displayName;
private static final String[] columns;
static {
columns = new String[]{
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_LAST_MODIFIED,
//DocumentsContract.Document.COLUMN_ICON,
DocumentsContract.Document.COLUMN_FLAGS,
DocumentsContract.Document.COLUMN_SIZE
};
}
/*
Based on https://github.com/rcketscientist/DocumentActivity
Extracted from android.support.v4.provider.DocumentsContractAPI19 and android.support.v4.provider.DocumentsContractAPI21
*/
private DocumentInfo(Context c, Uri uri)
{
this.uri = uri;
try (Cursor cursor = c.getContentResolver().query(uri, columns, null, null, null)) {
exists = cursor != null && cursor.getCount() > 0;
if (!exists)
return;
cursor.moveToFirst();
//String documentId = cursor.getString(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID));
final boolean readPerm = c.checkCallingOrSelfUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
== PackageManager.PERMISSION_GRANTED;
final boolean writePerm = c.checkCallingOrSelfUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
== PackageManager.PERMISSION_GRANTED;
final int flags = cursor.getInt(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_FLAGS));
final boolean supportsDelete = (flags & DocumentsContract.Document.FLAG_SUPPORTS_DELETE) != 0;
final boolean supportsCreate = (flags & DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE) != 0;
final boolean supportsWrite = (flags & DocumentsContract.Document.FLAG_SUPPORTS_WRITE) != 0;
String mimeType = cursor.getString(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE));
final boolean hasMime = !TextUtils.isEmpty(mimeType);
isDirectory = DocumentsContract.Document.MIME_TYPE_DIR.equals(mimeType);
isFile = !isDirectory && hasMime;
canRead = readPerm && hasMime;
canWrite = writePerm && (supportsDelete || (isDirectory && supportsCreate) || (hasMime && supportsWrite));
displayName = cursor.getString(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME));
lastModified = cursor.getLong(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_LAST_MODIFIED));
length = cursor.getLong(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_SIZE));
} catch (IllegalArgumentException e) {
//File does not exist, it's probably going to be created
exists = false;
canWrite = true;
}
}
}
}

View File

@@ -1,90 +0,0 @@
/*
* SPDX-FileCopyrightText: 2018 Erik Duisters <e.duisters1@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin
import android.content.Context
import android.net.Uri
import android.util.Log
import org.apache.commons.io.FileUtils
import org.apache.sshd.common.file.nativefs.NativeSshFile
import org.kde.kdeconnect.Helpers.MediaStoreHelper
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.OutputStream
import java.io.RandomAccessFile
internal class AndroidSshFile(
view: AndroidFileSystemView,
name: String,
file: File,
userName: String,
private val context: Context
) : NativeSshFile(view, name, file, userName) {
@Throws(IOException::class)
override fun createOutputStream(offset: Long): OutputStream {
if (!isWritable) {
throw IOException("No write permission : ${file.name}")
}
val raf = RandomAccessFile(file, "rw")
try {
if (offset < raf.length()) {
throw IOException("Your SSHFS is bugged") // SSHFS 3.0 and 3.2 cause data corruption, abort the transfer if this happens
}
raf.setLength(offset)
raf.seek(offset)
return object : FileOutputStream(raf.fd) {
@Throws(IOException::class)
override fun close() {
super.close()
raf.close()
}
}
} catch (e: IOException) {
raf.close()
throw e
}
}
override fun delete(): Boolean {
return super.delete().also {
if (it) {
MediaStoreHelper.indexFile(context, Uri.fromFile(file))
}
}
}
@Throws(IOException::class)
override fun create(): Boolean {
return super.create().also {
if (it) {
MediaStoreHelper.indexFile(context, Uri.fromFile(file))
}
}
}
// Based on https://github.com/wolpi/prim-ftpd/blob/master/primitiveFTPd/src/org/primftpd/filesystem/FsFile.java
override fun doesExist(): Boolean {
// file.exists() returns false when we don't have read permission
// try to figure out if it really does not exist
try {
return file.exists() || FileUtils.directoryContains(file.parentFile, file)
} catch (e: IOException) {
// An IllegalArgumentException is thrown if the parent is null or not a directory.
Log.d(TAG, "Exception: ", e)
} catch (e: IllegalArgumentException) {
Log.d(TAG, "Exception: ", e)
}
return false
}
companion object {
private val TAG: String = AndroidSshFile::class.java.simpleName
}
}

View File

@@ -1,32 +0,0 @@
/*
* SPDX-FileCopyrightText: 2023 Albert Vaca Cintora <albertvaka@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin
import org.apache.sshd.common.KeyExchange
import org.apache.sshd.common.NamedFactory
import org.apache.sshd.common.digest.SHA256
import org.apache.sshd.common.kex.AbstractDH
import org.apache.sshd.common.kex.DH
import org.apache.sshd.common.kex.DHGroupData
import org.apache.sshd.server.kex.AbstractDHGServer
class DHG14_256 : AbstractDHGServer() {
class Factory : NamedFactory<KeyExchange> {
override fun getName(): String = "diffie-hellman-group14-sha256"
override fun create(): KeyExchange {
return DHG14_256()
}
}
@Throws(Exception::class)
override fun getDH(): AbstractDH {
return DH(SHA256.Factory()).apply {
setG(DHGroupData.getG())
setP(DHGroupData.getP14())
}
}
}

View File

@@ -0,0 +1,32 @@
/*
* 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
import org.apache.sshd.common.digest.BuiltinDigests
import org.apache.sshd.common.kex.AbstractDH
import org.apache.sshd.common.kex.DHFactory
import org.apache.sshd.common.kex.DHG
import org.apache.sshd.common.kex.DHGroupData
import org.apache.sshd.common.util.SecurityUtils
import java.math.BigInteger
object DHG14_256Factory : DHFactory {
override fun getName(): String = "diffie-hellman-group14-sha256"
override fun isSupported(): Boolean = SecurityUtils.isBouncyCastleRegistered()
override fun isGroupExchange(): Boolean = false
override fun create(vararg params: Any?): AbstractDH {
require(params.isEmpty()) { "No accepted parameters for $name" }
return DHG(
BuiltinDigests.sha256,
BigInteger(DHGroupData.getP14()),
BigInteger(DHGroupData.getG())
)
}
}

View File

@@ -1,105 +0,0 @@
/*
* SPDX-FileCopyrightText: 2018 Erik Duisters <e.duisters1@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin
import org.apache.sshd.common.file.SshFile
import java.io.InputStream
import java.io.OutputStream
import java.util.Calendar
import java.util.Collections
import java.util.EnumMap
import java.util.EnumSet
// TODO: ls .. and ls / only show .. and / respectively I would expect a listing
// TODO: cd .. to / does not work and prints "Can't change directory: Can't check target"
internal class RootFile(
private val files: List<SshFile>,
private val userName: String,
private val exists: Boolean
) : SshFile {
override fun getAbsolutePath(): String = "/"
override fun getName(): String = "/"
override fun getAttributes(followLinks: Boolean): Map<SshFile.Attribute, Any> {
val attrs: MutableMap<SshFile.Attribute, Any> = EnumMap(SshFile.Attribute::class.java)
attrs[SshFile.Attribute.Size] = 0
attrs[SshFile.Attribute.Owner] = userName
attrs[SshFile.Attribute.Group] = userName
val p = EnumSet.noneOf(
SshFile.Permission::class.java
)
p.add(SshFile.Permission.UserExecute)
p.add(SshFile.Permission.GroupExecute)
p.add(SshFile.Permission.OthersExecute)
attrs[SshFile.Attribute.Permissions] = p
val now = Calendar.getInstance().timeInMillis
attrs[SshFile.Attribute.LastAccessTime] = now
attrs[SshFile.Attribute.LastModifiedTime] = now
attrs[SshFile.Attribute.IsSymbolicLink] = false
attrs[SshFile.Attribute.IsDirectory] = true
attrs[SshFile.Attribute.IsRegularFile] = false
return attrs
}
override fun setAttributes(attributes: Map<SshFile.Attribute, Any>) {}
override fun getAttribute(attribute: SshFile.Attribute, followLinks: Boolean): Any? = null
override fun setAttribute(attribute: SshFile.Attribute, value: Any) {}
override fun readSymbolicLink(): String = ""
override fun createSymbolicLink(destination: SshFile) {}
override fun getOwner(): String? = null
override fun isDirectory(): Boolean = true
override fun isFile(): Boolean = false
override fun doesExist(): Boolean = exists
override fun isReadable(): Boolean = true
override fun isWritable(): Boolean = false
override fun isExecutable(): Boolean = true
override fun isRemovable(): Boolean = false
override fun getParentFile(): SshFile = this
override fun getLastModified(): Long = 0
override fun setLastModified(time: Long): Boolean = false
override fun getSize(): Long = 0
override fun mkdir(): Boolean = false
override fun delete(): Boolean = false
override fun create(): Boolean = false
override fun truncate() {}
override fun move(destination: SshFile): Boolean = false
override fun listSshFiles(): List<SshFile> = Collections.unmodifiableList(files)
override fun createOutputStream(offset: Long): OutputStream? = null
override fun createInputStream(offset: Long): InputStream? = null
override fun handleClose() {
}
}

View File

@@ -1,5 +1,6 @@
/*
* SPDX-FileCopyrightText: 2014 Samoilenko Yuri <kinnalru@gmail.com>
* 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
*/

View File

@@ -1,16 +1,20 @@
/*
* SPDX-FileCopyrightText: 2023 Albert Vaca Cintora <albertvaka@gmail.com>
* 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
import org.apache.sshd.common.NamedFactory
import org.apache.sshd.common.Signature
import org.apache.sshd.common.signature.AbstractSignature
import org.apache.sshd.common.signature.Signature
import org.apache.sshd.common.signature.SignatureFactory
import org.apache.sshd.common.util.ValidateUtils
class SignatureRSASHA256 : AbstractSignature("SHA256withRSA") {
class Factory : NamedFactory<Signature> {
object Factory : SignatureFactory {
override fun isSupported(): Boolean = true
override fun getName(): String = "rsa-sha2-256"
override fun create(): Signature {
@@ -25,6 +29,18 @@ class SignatureRSASHA256 : AbstractSignature("SHA256withRSA") {
@Throws(Exception::class)
override fun verify(sig: ByteArray): Boolean {
return signature.verify(extractSig(sig))
var data = sig
val encoding = extractEncodedSignature(data)
if (encoding != null) {
val keyType = encoding.first
ValidateUtils.checkTrue(
"rsa-sha2-256" == keyType,
"Mismatched key type: %s",
keyType
)
data = encoding.second
}
return signature.verify(data)
}
}

View File

@@ -1,5 +1,6 @@
/*
* SPDX-FileCopyrightText: 2014 Samoilenko Yuri <kinnalru@gmail.com>
* 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
*/
@@ -8,30 +9,25 @@ package org.kde.kdeconnect.Plugins.SftpPlugin
import android.content.Context
import android.os.Build
import android.util.Log
import org.apache.sshd.SshServer
import org.apache.sshd.common.NamedFactory
import org.apache.sshd.common.file.nativefs.NativeFileSystemFactory
import org.apache.sshd.common.kex.BuiltinDHFactories
import org.apache.sshd.common.keyprovider.AbstractKeyPairProvider
import org.apache.sshd.common.signature.SignatureDSA
import org.apache.sshd.common.signature.SignatureECDSA.NISTP256Factory
import org.apache.sshd.common.signature.SignatureECDSA.NISTP384Factory
import org.apache.sshd.common.signature.SignatureECDSA.NISTP521Factory
import org.apache.sshd.common.signature.SignatureRSA
import org.apache.sshd.common.signature.BuiltinSignatures
import org.apache.sshd.common.util.SecurityUtils
import org.apache.sshd.server.Command
import org.apache.sshd.server.PasswordAuthenticator
import org.apache.sshd.server.PublickeyAuthenticator
import org.apache.sshd.server.SshServer
import org.apache.sshd.server.auth.password.PasswordAuthenticator
import org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator
import org.apache.sshd.server.command.ScpCommandFactory
import org.apache.sshd.server.kex.DHG14
import org.apache.sshd.server.kex.ECDHP256
import org.apache.sshd.server.kex.ECDHP384
import org.apache.sshd.server.kex.ECDHP521
import org.apache.sshd.server.kex.DHGServer
import org.apache.sshd.server.session.ServerSession
import org.apache.sshd.server.sftp.SftpSubsystem
import org.apache.sshd.server.subsystem.sftp.SftpSubsystemFactory
import org.kde.kdeconnect.Device
import org.kde.kdeconnect.Helpers.RandomHelper
import org.kde.kdeconnect.Helpers.SecurityHelpers.RsaHelper
import org.kde.kdeconnect.Helpers.SecurityHelpers.constantTimeCompare
import org.kde.kdeconnect.Plugins.SftpPlugin.saf.SafFileSystemFactory
import java.io.IOException
import java.nio.charset.StandardCharsets
import java.security.GeneralSecurityException
@@ -53,7 +49,7 @@ internal class SimpleSftpServer {
private val sshd: SshServer = SshServer.setUpDefaultServer()
private var safFileSystemFactory: AndroidFileSystemFactory? = null
private var safFileSystemFactory: SafFileSystemFactory? = null
fun setSafRoots(storageInfoList: List<SftpPlugin.StorageInfo>) {
safFileSystemFactory!!.initRoots(storageInfoList)
@@ -63,22 +59,25 @@ internal class SimpleSftpServer {
fun initialize(context: Context?, device: Device) {
sshd.signatureFactories =
listOf(
NISTP256Factory(),
NISTP384Factory(),
NISTP521Factory(),
SignatureDSA.Factory(),
SignatureRSASHA256.Factory(),
SignatureRSA.Factory() // Insecure SHA1, left for backwards compatibility
BuiltinSignatures.nistp256,
BuiltinSignatures.nistp384,
BuiltinSignatures.nistp521,
BuiltinSignatures.dsa,
SignatureRSASHA256.Factory,
BuiltinSignatures.rsa // Insecure SHA1, left for backwards compatibility
)
sshd.keyExchangeFactories =
listOf(
ECDHP256.Factory(), // ecdh-sha2-nistp256
ECDHP384.Factory(), // ecdh-sha2-nistp384
ECDHP521.Factory(), // ecdh-sha2-nistp521
DHG14_256.Factory(), // diffie-hellman-group14-sha256
DHG14.Factory() // Insecure diffie-hellman-group14-sha1, left for backwards-compatibility.
)
BuiltinDHFactories.ecdhp256, // ecdh-sha2-nistp256
BuiltinDHFactories.ecdhp384, // ecdh-sha2-nistp384
BuiltinDHFactories.ecdhp521, // ecdh-sha2-nistp521
DHG14_256Factory, // diffie-hellman-group14-sha256
BuiltinDHFactories.dhg14, // Insecure diffie-hellman-group14-sha1, left for backwards-compatibility.
).map {
DHGServer.newFactory(it)
}
// Reuse this device keys for the ssh connection as well
val keyPair = KeyPair(
@@ -92,12 +91,12 @@ internal class SimpleSftpServer {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
sshd.fileSystemFactory = NativeFileSystemFactory()
} else {
safFileSystemFactory = AndroidFileSystemFactory(context)
sshd.fileSystemFactory = safFileSystemFactory
safFileSystemFactory = SafFileSystemFactory(context!!)
sshd.fileSystemFactory = safFileSystemFactory // FIXME: This is not working
}
sshd.commandFactory = ScpCommandFactory()
sshd.subsystemFactories =
listOf<NamedFactory<Command>>(SftpSubsystem.Factory())
listOf<NamedFactory<Command>>(SftpSubsystemFactory())
keyAuth.deviceKey = device.certificate.publicKey

View File

@@ -0,0 +1,37 @@
package org.kde.kdeconnect.Plugins.SftpPlugin.saf
import android.content.Context
import android.util.Log
import org.apache.sshd.common.file.util.BaseFileSystem
import org.apache.sshd.common.file.util.ImmutableList
import java.nio.file.attribute.UserPrincipalLookupService
import java.nio.file.spi.FileSystemProvider
class SafFileSystem(
fileSystemProvider: FileSystemProvider,
roots: MutableMap<String, String?>,
username: String,
private val context: Context
) : BaseFileSystem<SafPath>(fileSystemProvider) {
override fun close() {
// no-op
Log.v(TAG, "close")
}
override fun isOpen(): Boolean = true
override fun supportedFileAttributeViews(): Set<String> = setOf("basic")
override fun getUserPrincipalLookupService(): UserPrincipalLookupService {
throw UnsupportedOperationException("SAF does not support user principal lookup")
}
override fun create(root: String, names: ImmutableList<String>): SafPath {
Log.v(TAG, "create: $root, $names")
return SafPath(this, root, names)
}
companion object {
private const val TAG = "SafFileSystem"
}
}

View File

@@ -0,0 +1,48 @@
/*
* SPDX-FileCopyrightText: 2018 Erik Duisters <e.duisters1@gmail.com>
* 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 android.content.Context
import android.util.Log
import org.apache.sshd.common.session.Session
import org.apache.sshd.common.file.FileSystemFactory
import org.kde.kdeconnect.Plugins.SftpPlugin.SftpPlugin
import java.nio.file.FileSystem
class SafFileSystemFactory(private val context: Context) : FileSystemFactory {
private val provider = SafFileSystemProvider()
private val roots: MutableMap<String, String?> = HashMap()
fun initRoots(storageInfoList: List<SftpPlugin.StorageInfo>) {
Log.i(TAG, "initRoots: $storageInfoList")
for (curStorageInfo in storageInfoList) {
when {
curStorageInfo.isFileUri -> {
TODO("File URI is not supported yet")
// if (curStorageInfo.uri.path != null) {
// roots[curStorageInfo.displayName] = curStorageInfo.uri.path
// }
}
curStorageInfo.isContentUri -> {
roots[curStorageInfo.displayName] = curStorageInfo.uri.toString()
}
else -> {
Log.e(TAG, "Unknown storage URI type: $curStorageInfo")
}
}
}
}
override fun createFileSystem(session: Session): FileSystem {
return SafFileSystem(provider, roots, session.username, context)
}
companion object {
private const val TAG = "SafFileSystemFactory"
}
}

View File

@@ -0,0 +1,113 @@
package org.kde.kdeconnect.Plugins.SftpPlugin.saf
import java.net.URI
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.LinkOption
import java.nio.file.OpenOption
import java.nio.file.Path
import java.nio.file.attribute.BasicFileAttributes
import java.nio.file.attribute.FileAttribute
import java.nio.file.attribute.FileAttributeView
import java.nio.file.spi.FileSystemProvider
class SafFileSystemProvider: FileSystemProvider() {
override fun getScheme(): String = "saf"
override fun newFileSystem(uri: URI, env: MutableMap<String, *>?): FileSystem {
TODO("Not yet implemented")
}
override fun getFileSystem(uri: URI): FileSystem {
TODO("Not yet implemented")
}
override fun getPath(uri: URI): Path {
TODO("Not yet implemented")
}
override fun newByteChannel(
path: Path,
options: MutableSet<out OpenOption>,
vararg attrs: FileAttribute<*>
): SeekableByteChannel {
TODO("Not yet implemented")
}
override fun newDirectoryStream(
dir: Path,
filter: DirectoryStream.Filter<in Path>
): DirectoryStream<Path> {
TODO("Not yet implemented")
}
override fun createDirectory(dir: Path, vararg attrs: FileAttribute<*>) {
TODO("Not yet implemented")
}
override fun delete(path: Path) {
TODO("Not yet implemented")
}
override fun copy(source: Path, target: Path, vararg options: CopyOption) {
TODO("Not yet implemented")
}
override fun move(source: Path, target: Path, vararg options: CopyOption) {
TODO("Not yet implemented")
}
override fun isSameFile(path: Path, path2: Path): Boolean {
TODO("Not yet implemented")
}
override fun isHidden(path: Path): Boolean {
TODO("Not yet implemented")
}
override fun getFileStore(path: Path): FileStore {
TODO("Not yet implemented")
}
override fun checkAccess(path: Path, vararg modes: AccessMode) {
TODO("Not yet implemented")
}
override fun <V : FileAttributeView> getFileAttributeView(
path: Path,
type: Class<V>,
vararg options: LinkOption?
): V {
TODO("Not yet implemented")
}
override fun <A : BasicFileAttributes> readAttributes(
path: Path,
type: Class<A>,
vararg options: LinkOption?
): A {
// TODO
throw UnsupportedOperationException("readAttributes($path)[${type.getSimpleName()}] N/A");
}
override fun readAttributes(
path: Path,
attributes: String,
vararg options: LinkOption?
): MutableMap<String, Any?> {
TODO("Not yet implemented")
}
override fun setAttribute(
path: Path,
attribute: String,
value: Any?,
vararg options: LinkOption?
) {
TODO("Not yet implemented")
}
}

View File

@@ -0,0 +1,15 @@
package org.kde.kdeconnect.Plugins.SftpPlugin.saf
import org.apache.sshd.common.file.util.BasePath
import org.apache.sshd.common.file.util.ImmutableList
import java.nio.file.LinkOption
import java.nio.file.Path
class SafPath(
fileSystem: SafFileSystem,
root: String, names: ImmutableList<String>
) : BasePath<SafPath, SafFileSystem>(fileSystem, root, names) {
override fun toRealPath(vararg options: LinkOption?): Path {
return this // FIXME
}
}