diff --git a/build.gradle b/build.gradle --- a/build.gradle +++ b/build.gradle @@ -15,6 +15,7 @@ defaultConfig { minSdkVersion 14 targetSdkVersion 28 + //TODO: vectorDrawables.useSupportLibrary = true //multiDexEnabled true //testInstrumentationRunner "com.android.test.runner.MultiDexTestRunner" } @@ -67,6 +68,11 @@ repositories { jcenter() google() + /* Needed for org.apache.sshd debugging + maven { + url "https://jitpack.io" + } + */ } implementation 'androidx.media:media:1.0.0' @@ -78,6 +84,7 @@ implementation 'org.apache.sshd:sshd-core:0.14.0' implementation 'org.apache.mina:mina-core:2.0.19' //For some reason, makes sshd-core:0.14.0 work without NIO, which isn't available until Android 8+ + //implementation('com.github.bright:slf4android:0.1.6') { transitive = true } // For org.apache.sshd debugging implementation 'com.madgag.spongycastle:bcpkix-jdk15on:1.58.0.0' //For SSL certificate generation implementation 'com.jakewharton:butterknife:10.0.0' diff --git a/res/drawable/ic_arrow_drop_down_24px.xml b/res/drawable/ic_arrow_drop_down_24px.xml new file mode 100644 --- /dev/null +++ b/res/drawable/ic_arrow_drop_down_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/res/drawable/ic_delete.xml b/res/drawable/ic_delete.xml new file mode 100644 --- /dev/null +++ b/res/drawable/ic_delete.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/res/layout/fragment_storage_preference_dialog.xml b/res/layout/fragment_storage_preference_dialog.xml new file mode 100644 --- /dev/null +++ b/res/layout/fragment_storage_preference_dialog.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/res/layout/preference_checkbox.xml b/res/layout/preference_checkbox.xml new file mode 100644 --- /dev/null +++ b/res/layout/preference_checkbox.xml @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/res/menu/sftp_settings_action_mode.xml b/res/menu/sftp_settings_action_mode.xml new file mode 100644 --- /dev/null +++ b/res/menu/sftp_settings_action_mode.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/res/values/strings.xml b/res/values/strings.xml --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -227,6 +227,30 @@ SD card (read only) Camera pictures + Detected SD cards + Edit SD card + Configured storage locations + Add storage location + Edit storage location + Add camera folder shortcut + Add a shortcut to the camera folder + Do not add a shortcut to the camera folder + key_sftp_preference_category + key_sftp_add_storage + key_sftp_add_camera_shotcut + key_sftp_storage_info%d" + key_sftp_storage_info_list + Storage location + This location has already been configured + click to select + Display name + This display name is already used + Display name cannot be empty + Delete + No SD card detected + No storage locations configured + No SD card was detected, do you have one installed? + To access files remotely you have to configure storage locations Add host/IP Hostname or IP No players found @@ -260,7 +284,6 @@ This plugin needs permissions to work You need to grant extra permissions to enable all functions Some plugins have features disabled because of lack of permission (tap for more info): - To access your files from your PC the app needs permission to access your phone\'s storage To share files between your phone and your desktop you need to give access to the phone\'s storage To read and write SMS from your desktop you need to give permission to SMS To see phone calls and SMS from the desktop you need to give permission to phone calls and SMS diff --git a/res/values/styles.xml b/res/values/styles.xml --- a/res/values/styles.xml +++ b/res/values/styles.xml @@ -17,6 +17,7 @@ @android:color/black @android:color/black @style/PreferenceThemeOverlay + @style/ActionModeStyle + + diff --git a/res/xml/sftpplugin_preferences.xml b/res/xml/sftpplugin_preferences.xml new file mode 100644 --- /dev/null +++ b/res/xml/sftpplugin_preferences.xml @@ -0,0 +1,22 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/org/kde/kdeconnect/Helpers/StorageHelper.java b/src/org/kde/kdeconnect/Helpers/StorageHelper.java --- a/src/org/kde/kdeconnect/Helpers/StorageHelper.java +++ b/src/org/kde/kdeconnect/Helpers/StorageHelper.java @@ -20,7 +20,12 @@ package org.kde.kdeconnect.Helpers; +import android.annotation.TargetApi; +import android.content.Context; +import android.net.Uri; +import android.os.Build; import android.os.Environment; +import android.provider.DocumentsContract; import java.io.BufferedReader; import java.io.File; @@ -32,6 +37,8 @@ import java.util.Scanner; import java.util.StringTokenizer; +import androidx.annotation.NonNull; + //Code from http://stackoverflow.com/questions/9340332/how-can-i-get-the-list-of-mounted-external-storage-of-android-device/19982338#19982338 //modified to work on Lollipop and other devices public class StorageHelper { @@ -43,7 +50,7 @@ public final boolean removable; public final int number; - StorageInfo(String path, boolean readonly, boolean removable, int number) { + public StorageInfo(String path, boolean readonly, boolean removable, int number) { this.path = path; this.readonly = readonly; this.removable = removable; @@ -77,7 +84,7 @@ } File storage = new File("/storage/"); - if (storage.exists() && storage.isDirectory()) { + if (storage.exists() && storage.isDirectory() && storage.canRead()) { String mounts = null; try (Scanner scanner = new Scanner(new File("/proc/mounts"))) { mounts = scanner.useDelimiter("\\A").next(); @@ -100,7 +107,7 @@ if (!path.startsWith("/storage/emulated") || dirs.length == 1) { if (!paths.contains(path) && !paths.contains(path2)) { if (mounts == null || mounts.contains(path) || mounts.contains(path2)) { - list.add(0, new StorageInfo(path, false, true, cur_removable_number++)); + list.add(0, new StorageInfo(path, dir.canWrite(), true, cur_removable_number++)); paths.add(path); } } @@ -153,4 +160,37 @@ return list; } + /* treeUri documentId + * ================================================================================================== + * content://com.android.providers.downloads.documents/tree/downloads => downloads + * content://com.android.externalstorage.documents/tree/1715-1D1F: => 1715-1D1F: + * content://com.android.externalstorage.documents/tree/1715-1D1F:My%20Photos => 1715-1D1F:My Photos + * content://com.android.externalstorage.documents/tree/primary: => primary: + * content://com.android.externalstorage.documents/tree/primary:DCIM => primary:DCIM + * content://com.android.externalstorage.documents/tree/primary:Download/bla => primary:Download/bla + */ + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public static String getDisplayName(@NonNull Context context, @NonNull Uri treeUri) { + List pathSegments = treeUri.getPathSegments(); + + if (!pathSegments.get(0).equals("tree")) { + throw new IllegalArgumentException("treeUri is not valid"); + } + + String documentId = DocumentsContract.getTreeDocumentId(treeUri); + + int colonIdx = pathSegments.get(1).indexOf(':'); + + if (colonIdx >= 0) { + String tree = pathSegments.get(1).substring(0, colonIdx + 1); + + if (!documentId.equals(tree)) { + return documentId.substring(tree.length()); + } else { + return documentId.substring(0, colonIdx); + } + } + + return documentId; + } } diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/AndroidFileSystemFactory.java b/src/org/kde/kdeconnect/Plugins/SftpPlugin/AndroidFileSystemFactory.java new file mode 100644 --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/SftpPlugin/AndroidFileSystemFactory.java @@ -0,0 +1,70 @@ +/* + * Copyright 2018 Erik Duisters + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License or (at your option) version 3 or any later version + * accepted by the membership of KDE e.V. (or its successor approved + * by the membership of KDE e.V.), which shall act as a proxy + * defined in Section 14 of version 3 of the license. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kde.kdeconnect.Plugins.SftpPlugin; + +import android.content.Context; +import android.os.Build; + +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 roots; + + AndroidFileSystemFactory(Context context) { + this.context = context; + this.roots = new HashMap<>(); + } + + void initRoots(List 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) { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) { + if (roots.size() == 0) { + throw new RuntimeException("roots cannot be empty"); + } + + String[] rootsAsString = new String[roots.size()]; + roots.keySet().toArray(rootsAsString); + + return new AndroidFileSystemView(roots, rootsAsString[0], username.getUsername(), context); + } else { + return new AndroidSafFileSystemView(roots, username.getUsername(), context); + } + } +} diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/AndroidFileSystemView.java b/src/org/kde/kdeconnect/Plugins/SftpPlugin/AndroidFileSystemView.java new file mode 100644 --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/SftpPlugin/AndroidFileSystemView.java @@ -0,0 +1,115 @@ +/* + * Copyright 2018 Erik Duisters + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License or (at your option) version 3 or any later version + * accepted by the membership of KDE e.V. (or its successor approved + * by the membership of KDE e.V.), which shall act as a proxy + * defined in Section 14 of version 3 of the license. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +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 roots; + private final RootFile rootFile; + + AndroidFileSystemView(Map 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 createFileList() { + List list = new ArrayList<>(); + for (Map.Entry 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; + } +} diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/AndroidSafFileSystemView.java b/src/org/kde/kdeconnect/Plugins/SftpPlugin/AndroidSafFileSystemView.java new file mode 100644 --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/SftpPlugin/AndroidSafFileSystemView.java @@ -0,0 +1,128 @@ +/* + * Copyright 2018 Erik Duisters + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License or (at your option) version 3 or any later version + * accepted by the membership of KDE e.V. (or its successor approved + * by the membership of KDE e.V.), which shall act as a proxy + * defined in Section 14 of version 3 of the license. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kde.kdeconnect.Plugins.SftpPlugin; + +import android.annotation.TargetApi; +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; + +@TargetApi(21) +public class AndroidSafFileSystemView implements FileSystemView { + final String userName; + final Context context; + private final Map roots; + private final RootFile rootFile; + + AndroidSafFileSystemView(Map roots, String userName, Context context) { + this.roots = roots; + this.userName = userName; + this.context = context; + this.rootFile = new RootFile( createFileList(), userName, true); + } + + private List createFileList() { + List list = new ArrayList<>(); + for (Map.Entry 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 { + //ChildDocument, strip the leading / from nameWithoutRoot and append that to the treeDocumentId + String treeDocumentId = DocumentsContract.getTreeDocumentId(treeUri); + File nameWithoutRootFile = new File(nameWithoutRoot); + String parentSuffix = nameWithoutRootFile.getParent(); + String parentDocumentId = treeDocumentId + (parentSuffix.equals("/") ? "" : parentSuffix.substring(1)); + + Uri parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, parentDocumentId); + Uri documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, treeDocumentId + nameWithoutRoot.substring(1)); + + 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; + } +} diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/AndroidSafSshFile.java b/src/org/kde/kdeconnect/Plugins/SftpPlugin/AndroidSafSshFile.java new file mode 100644 --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/SftpPlugin/AndroidSafSshFile.java @@ -0,0 +1,499 @@ +/* + * Copyright 2018 Erik Duisters + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License or (at your option) version 3 or any later version + * accepted by the membership of KDE e.V. (or its successor approved + * by the membership of KDE e.V.), which shall act as a proxy + * defined in Section 14 of version 3 of the license. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kde.kdeconnect.Plugins.SftpPlugin; + +import android.annotation.TargetApi; +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 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; + +import androidx.annotation.Nullable; + +@TargetApi(21) +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); + } + } catch (FileNotFoundException ignored) {} + + return uri != null; + } + + @Override + public void truncate() throws IOException { + 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 >= 24) { + try { + Uri newUri = DocumentsContract.moveDocument(fileSystemView.context.getContentResolver(), documentInfo.uri, parentUri, destParentUri); + if (newUri != null) { + success = true; + parentUri = destParentUri; + documentInfo.uri = newUri; + } + } catch (Exception ignored) { + Log.e(TAG,"DocumentsContract.moveDocument() threw an exception: " + ignored.getMessage()); + } + } 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 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 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 { + return fileSystemView.context.getContentResolver().openOutputStream(documentInfo.uri); + } + + @Override + public InputStream createInputStream(final long offset) throws IOException { + return fileSystemView.context.getContentResolver().openInputStream(documentInfo.uri); + } + + @Override + public void handleClose() { + // Nop + } + + @Override + public Map getAttributes(boolean followLinks) throws IOException { + Map 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) throws IOException { + Object ret; + + switch (attribute) { + case Size: + ret = documentInfo.length; + break; + case Uid: + ret = 1; + break; + case Owner: + ret = getOwner(); + break; + case Gid: + ret = 1; + break; + 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 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: + ret = documentInfo.lastModified; + break; + case LastModifiedTime: + ret = documentInfo.lastModified; + break; + case LastAccessTime: + ret = documentInfo.lastModified; + break; + case NLink: + ret = 0; + break; + default: + ret = null; + break; + } + + return ret; + } + + @Override + public void setAttributes(Map attributes) { + //TODO: Using Java 7 NIO it should be possible to implement setting a number of attributes but does SaF allow that? + Log.d(TAG, "setAttributes()"); + } + + @Override + public void setAttribute(Attribute attribute, Object value) throws IOException { + Log.d(TAG, "setAttribute()"); + } + + @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; + @Nullable + private String documentId; + private boolean canRead; + private boolean canWrite; + @Nullable + private String mimeType; + 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(); + + 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; + 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; + } + } + } +} diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/AndroidSshFile.java b/src/org/kde/kdeconnect/Plugins/SftpPlugin/AndroidSshFile.java new file mode 100644 --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/SftpPlugin/AndroidSshFile.java @@ -0,0 +1,114 @@ +/* + * Copyright 2018 Erik Duisters + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License or (at your option) version 3 or any later version + * accepted by the membership of KDE e.V. (or its successor approved + * by the membership of KDE e.V.), which shall act as a proxy + * defined in Section 14 of version 3 of the license. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kde.kdeconnect.Plugins.SftpPlugin; + +import android.content.Context; +import android.net.Uri; + +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; + +class AndroidSshFile extends NativeSshFile { + private final static String TAG = AndroidSshFile.class.getSimpleName(); + final private Context context; + final private File file; + + AndroidSshFile(final AndroidFileSystemView view, String name, final File file, final String userName, Context context) { + super(view, name, file, userName); + this.context = context; + this.file = file; + } + + @Override + public OutputStream createOutputStream(long offset) throws IOException { + if (!isWritable()) { + throw new IOException("No write permission : " + file.getName()); + } + + final RandomAccessFile raf = new RandomAccessFile(file, "rw"); + try { + if (offset < raf.length()) { + throw new 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 new FileOutputStream(raf.getFD()) { + public void close() throws IOException { + super.close(); + raf.close(); + } + }; + } catch (IOException e) { + raf.close(); + throw e; + } + } + + @Override + public boolean delete() { + boolean ret = super.delete(); + if (ret) { + MediaStoreHelper.indexFile(context, Uri.fromFile(file)); + } + return ret; + + } + + @Override + public boolean create() throws IOException { + boolean ret = super.create(); + if (ret) { + MediaStoreHelper.indexFile(context, Uri.fromFile(file)); + } + return ret; + + } + + // Based on https://github.com/wolpi/prim-ftpd/blob/master/primitiveFTPd/src/org/primftpd/filesystem/FsFile.java + @Override + public boolean doesExist() { + boolean exists = file.exists(); + + if (!exists) { + // file.exists() returns false when we don't have read permission + // try to figure out if it really does not exist + File parentFile = file.getParentFile(); + File[] children = parentFile.listFiles(); + if (children != null) { + for (File child : children) { + if (file.equals(child)) { + exists = true; + break; + } + } + } + } + + return exists; + } +} diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/RootFile.java b/src/org/kde/kdeconnect/Plugins/SftpPlugin/RootFile.java new file mode 100644 --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/SftpPlugin/RootFile.java @@ -0,0 +1,178 @@ +/* + * Copyright 2018 Erik Duisters + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License or (at your option) version 3 or any later version + * accepted by the membership of KDE e.V. (or its successor approved + * by the membership of KDE e.V.), which shall act as a proxy + * defined in Section 14 of version 3 of the license. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kde.kdeconnect.Plugins.SftpPlugin; + +import org.apache.sshd.common.file.SshFile; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Calendar; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +//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" +class RootFile implements SshFile { + private final boolean exists; + private final String userName; + private final List files; + + RootFile(List files, String userName, boolean exits) { + this.files = files; + this.userName = userName; + this.exists = exits; + } + + public String getAbsolutePath() { + return "/"; + } + + public String getName() { + return "/"; + } + + public Map getAttributes(boolean followLinks) throws IOException { + Map attrs = new HashMap<>(); + + attrs.put(Attribute.Size, 0); + attrs.put(Attribute.Owner, userName); + attrs.put(Attribute.Group, userName); + + EnumSet p = EnumSet.noneOf(Permission.class); + p.add(Permission.UserExecute); + p.add(Permission.GroupExecute); + p.add(Permission.OthersExecute); + attrs.put(Attribute.Permissions, p); + + long now = Calendar.getInstance().getTimeInMillis(); + attrs.put(Attribute.LastAccessTime, now); + attrs.put(Attribute.LastModifiedTime, now); + + attrs.put(Attribute.IsSymbolicLink, false); + attrs.put(Attribute.IsDirectory, true); + attrs.put(Attribute.IsRegularFile, false); + + return attrs; + } + + public void setAttributes(Map attributes) { + } + + public Object getAttribute(Attribute attribute, boolean followLinks) { + return null; + } + + public void setAttribute(Attribute attribute, Object value) { + } + + public String readSymbolicLink() { + return null; + } + + public void createSymbolicLink(SshFile destination) { + } + + public String getOwner() { + return null; + } + + public boolean isDirectory() { + return true; + } + + public boolean isFile() { + return false; + } + + public boolean doesExist() { + return exists; + } + + public boolean isReadable() { + return true; + } + + public boolean isWritable() { + return false; + } + + public boolean isExecutable() { + return true; + } + + public boolean isRemovable() { + return false; + } + + public SshFile getParentFile() { + return this; + } + + public long getLastModified() { + return 0; + } + + public boolean setLastModified(long time) { + return false; + } + + public long getSize() { + return 0; + } + + public boolean mkdir() { + return false; + } + + public boolean delete() { + return false; + } + + public boolean create() { + return false; + } + + public void truncate() { + } + + public boolean move(SshFile destination) { + return false; + } + + public List listSshFiles() { + return Collections.unmodifiableList(files); + } + + public OutputStream createOutputStream(long offset) { + return null; + } + + public InputStream createInputStream(long offset) { + return null; + } + + public void handleClose() { + } +} diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/SftpPlugin.java b/src/org/kde/kdeconnect/Plugins/SftpPlugin/SftpPlugin.java --- a/src/org/kde/kdeconnect/Plugins/SftpPlugin/SftpPlugin.java +++ b/src/org/kde/kdeconnect/Plugins/SftpPlugin/SftpPlugin.java @@ -20,26 +20,41 @@ package org.kde.kdeconnect.Plugins.SftpPlugin; -import android.Manifest; +import android.app.Activity; +import android.content.ContentResolver; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; import android.os.Build; -import android.os.Environment; -import org.kde.kdeconnect.Helpers.StorageHelper; +import org.json.JSONException; +import org.json.JSONObject; import org.kde.kdeconnect.NetworkPacket; import org.kde.kdeconnect.Plugins.Plugin; +import org.kde.kdeconnect.UserInterface.DeviceSettingsActivity; +import org.kde.kdeconnect.UserInterface.PluginSettingsFragment; import org.kde.kdeconnect_tp.R; import java.io.File; import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; import java.util.List; -public class SftpPlugin extends Plugin { +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.preference.PreferenceManager; + +public class SftpPlugin extends Plugin implements SharedPreferences.OnSharedPreferenceChangeListener { private final static String PACKET_TYPE_SFTP = "kdeconnect.sftp"; private final static String PACKET_TYPE_SFTP_REQUEST = "kdeconnect.sftp.request"; private static final SimpleSftpServer server = new SimpleSftpServer(); + private String KeyStorageInfoList; + private String KeyAddCameraShortcut; + @Override public String getDisplayName() { return context.getResources().getString(R.string.pref_plugin_sftp); @@ -52,79 +67,115 @@ @Override public boolean onCreate() { - permissionExplanation = R.string.sftp_permission_explanation; + KeyStorageInfoList = context.getString(R.string.sftp_preference_key_storage_info_list); + KeyAddCameraShortcut = context.getString(R.string.sftp_preference_key_add_camera_shortcut); + try { server.init(context, device); + + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) { + return SftpSettingsFragment.getStorageInfoList(context).size() != 0; + } + return true; } catch (Exception e) { e.printStackTrace(); return false; } } + @Override + public boolean checkOptionalPermissions() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + return SftpSettingsFragment.getStorageInfoList(context).size() != 0; + } + + return true; + } + + @Override + public AlertDialog getErrorDialog(Activity deviceActivity) { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT && SftpSettingsFragment.getStorageInfoList(context).size() == 0) { + return new AlertDialog.Builder(deviceActivity) + .setTitle(getDisplayName()) + .setMessage(R.string.sftp_no_sdcard_detected_explanation) + .setPositiveButton(R.string.ok, (dialogInterface, i) -> { + //Do nothing + }) + .create(); + } + + return null; + } + + @Override + public AlertDialog getOptionalPermissionExplanationDialog(Activity deviceActivity) { + return new AlertDialog.Builder(deviceActivity) + .setTitle(getDisplayName()) + .setMessage(R.string.sftp_saf_permission_explanation) + .setPositiveButton(R.string.ok, (dialogInterface, i) -> { + Intent intent = new Intent(deviceActivity, DeviceSettingsActivity.class); + intent.putExtra(DeviceSettingsActivity.EXTRA_DEVICE_ID, device.getDeviceId()); + intent.putExtra(DeviceSettingsActivity.EXTRA_PLUGIN_KEY, getPluginKey()); + //deviceActivity.startActivityForResult(intent, MainActivity.RESULT_NEEDS_RELOAD); + deviceActivity.startActivity(intent); + }) + .setNegativeButton(R.string.cancel, (dialogInterface, i) -> { + //Do nothing + }) + .create(); + } + @Override public void onDestroy() { server.stop(); + PreferenceManager.getDefaultSharedPreferences(context).unregisterOnSharedPreferenceChangeListener(this); } @Override public boolean onPacketReceived(NetworkPacket np) { - if (np.getBoolean("startBrowsing")) { - if (server.start()) { + ArrayList paths = new ArrayList<>(); + ArrayList pathNames = new ArrayList<>(); + List storageInfoList = SftpSettingsFragment.getStorageInfoList(context); + Collections.sort(storageInfoList, new StorageInfo.UriNameComparator()); + + if (storageInfoList.size() > 0) { + getPathsAndNamesForStorageInfoList(paths, pathNames, storageInfoList); + } else { NetworkPacket np2 = new NetworkPacket(PACKET_TYPE_SFTP); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + np2.set("errorMessage", context.getString(R.string.sftp_no_storage_locations_configured)); + } else { + np2.set("errorMessage", context.getString(R.string.sftp_no_sdcard_detected)); + } + + device.sendPacket(np2); + + return true; + } + + removeChildren(storageInfoList); + + if (server.start(storageInfoList)) { + PreferenceManager.getDefaultSharedPreferences(context).registerOnSharedPreferenceChangeListener(this); + + NetworkPacket np2 = new NetworkPacket(PACKET_TYPE_SFTP); + + //TODO: ip is not used on desktop any more remove both here and from desktop code when nobody ships 1.2.0 np2.set("ip", server.getLocalIpAddress()); np2.set("port", server.getPort()); np2.set("user", SimpleSftpServer.USER); np2.set("password", server.getPassword()); //Kept for compatibility, in case "multiPaths" is not possible or the other end does not support it - np2.set("path", Environment.getExternalStorageDirectory().getAbsolutePath()); - - List storageList = StorageHelper.getStorageList(); - ArrayList paths = new ArrayList<>(); - ArrayList pathNames = new ArrayList<>(); - - for (StorageHelper.StorageInfo storage : storageList) { - paths.add(storage.path); - StringBuilder res = new StringBuilder(); - - if (storageList.size() > 1) { - if (!storage.removable) { - res.append(context.getString(R.string.sftp_internal_storage)); - } else if (storage.number > 1) { - res.append(context.getString(R.string.sftp_sdcard_num, storage.number)); - } else { - res.append(context.getString(R.string.sftp_sdcard)); - } - } else { - res.append(context.getString(R.string.sftp_all_files)); - } - String pathName = res.toString(); - if (storage.readonly) { - res.append(" "); - res.append(context.getString(R.string.sftp_readonly)); - } - pathNames.add(res.toString()); - - //Shortcut for users that only want to browse camera pictures - String dcim = storage.path + "/DCIM/Camera"; - if (new File(dcim).exists()) { - paths.add(dcim); - if (storageList.size() > 1) { - pathNames.add(context.getString(R.string.sftp_camera) + "(" + pathName + ")"); - } else { - pathNames.add(context.getString(R.string.sftp_camera)); - } - } - } + np2.set("path", "/"); if (paths.size() > 0) { np2.set("multiPaths", paths); np2.set("pathNames", pathNames); - } device.sendPacket(np2); @@ -135,12 +186,66 @@ return false; } - @Override - public String[] getRequiredPermissions() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { - return new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}; - } else { - return new String[0]; + private void getPathsAndNamesForStorageInfoList(List paths, List pathNames, List storageInfoList) { + StorageInfo prevInfo = null; + StringBuilder pathBuilder = new StringBuilder(); + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + boolean addCameraShortcuts = false; + + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) { + addCameraShortcuts = prefs.getBoolean(context.getString(R.string.sftp_preference_key_add_camera_shortcut), false); + } + + for (StorageInfo curInfo : storageInfoList) { + pathBuilder.setLength(0); + pathBuilder.append("/"); + + if (prevInfo != null && curInfo.uri.toString().startsWith(prevInfo.uri.toString())) { + pathBuilder.append(prevInfo.displayName); + pathBuilder.append("/"); + if (curInfo.uri.getPath() != null && prevInfo.uri.getPath() != null) { + pathBuilder.append(curInfo.uri.getPath().substring(prevInfo.uri.getPath().length())); + } else { + throw new RuntimeException("curInfo.uri.getPath() or parentInfo.uri.getPath() returned null"); + } + } else { + pathBuilder.append(curInfo.displayName); + + if (prevInfo == null || !curInfo.uri.toString().startsWith(prevInfo.uri.toString())) { + prevInfo = curInfo; + } + } + + paths.add(pathBuilder.toString()); + pathNames.add(curInfo.displayName); + + if (addCameraShortcuts) { + if (new File(curInfo.uri.getPath(), "/DCIM/Camera").exists()) { + paths.add(pathBuilder.toString() + "/DCIM/Camera"); + if (storageInfoList.size() > 1) { + pathNames.add(context.getString(R.string.sftp_camera) + "(" + curInfo.displayName + ")"); + } else { + pathNames.add(context.getString(R.string.sftp_camera)); + } + } + } + } + } + + private void removeChildren(List storageInfoList) { + StorageInfo prevInfo = null; + Iterator it = storageInfoList.iterator(); + + while (it.hasNext()) { + StorageInfo curInfo = it.next(); + + if (prevInfo != null && curInfo.uri.toString().startsWith(prevInfo.uri.toString())) { + it.remove(); + } else { + if (prevInfo == null || !curInfo.uri.toString().startsWith(prevInfo.uri.toString())) { + prevInfo = curInfo; + } + } } } @@ -154,4 +259,102 @@ return new String[]{PACKET_TYPE_SFTP}; } + @Override + public boolean hasSettings() { + return true; + } + + @Override + public PluginSettingsFragment getSettingsFragment() { + return SftpSettingsFragment.newInstance(getPluginKey()); + } + + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { + if (key.equals(KeyStorageInfoList) || key.equals(KeyAddCameraShortcut)) { + //TODO: There used to be a way to request an un-mount (see desktop SftpPlugin's Mounter::onPackageReceived) but that is not handled anymore by the SftpPlugin on KDE. + if (server.isStarted()) { + server.stop(); + + NetworkPacket np = new NetworkPacket(PACKET_TYPE_SFTP_REQUEST); + np.set("startBrowsing", true); + onPacketReceived(np); + } + } + } + + static class StorageInfo { + private static final String KEY_DISPLAY_NAME = "DisplayName"; + private static final String KEY_URI = "Uri"; + + @NonNull String displayName; + @NonNull Uri uri; + + StorageInfo(@NonNull String displayName, @NonNull Uri uri) { + this.displayName = displayName; + this.uri = uri; + } + + static StorageInfo copy(StorageInfo from) { + //Both String and Uri are immutable + return new StorageInfo(from.displayName, from.uri); + } + + boolean isFileUri() { + return uri.getScheme().equals(ContentResolver.SCHEME_FILE); + } + + boolean isContentUri() { + return uri.getScheme().equals(ContentResolver.SCHEME_CONTENT); + } + + public JSONObject toJSON() throws JSONException { + JSONObject jsonObject = new JSONObject(); + + jsonObject.put(KEY_DISPLAY_NAME, displayName); + jsonObject.put(KEY_URI, uri.toString()); + + return jsonObject; + } + + @NonNull + static StorageInfo fromJSON(@NonNull JSONObject jsonObject) throws JSONException { + String displayName = jsonObject.getString(KEY_DISPLAY_NAME); + Uri uri = Uri.parse(jsonObject.getString(KEY_URI)); + + return new StorageInfo(displayName, uri); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + StorageInfo that = (StorageInfo) o; + + if (!displayName.equals(that.displayName)) return false; + return uri.equals(that.uri); + } + + @Override + public int hashCode() { + int result = displayName.hashCode(); + result = 31 * result + uri.hashCode(); + return result; + } + + static class DisplayNameComparator implements java.util.Comparator { + @Override + public int compare(StorageInfo si1, StorageInfo si2) { + return si1.displayName.compareToIgnoreCase(si2.displayName); + } + } + + static class UriNameComparator implements java.util.Comparator { + @Override + public int compare(StorageInfo si1, StorageInfo si2) { + return si1.uri.compareTo(si2.uri); + } + } + } } diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/SftpSettingsFragment.java b/src/org/kde/kdeconnect/Plugins/SftpPlugin/SftpSettingsFragment.java new file mode 100644 --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/SftpPlugin/SftpSettingsFragment.java @@ -0,0 +1,499 @@ +/* + * Copyright 2018 Erik Duisters + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License or (at your option) version 3 or any later version + * accepted by the membership of KDE e.V. (or its successor approved + * by the membership of KDE e.V.), which shall act as a proxy + * defined in Section 14 of version 3 of the license. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kde.kdeconnect.Plugins.SftpPlugin; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.res.TypedArray; +import android.graphics.PorterDuff; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Environment; +import android.util.SparseBooleanArray; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.kde.kdeconnect.BackgroundService; +import org.kde.kdeconnect.Device; +import org.kde.kdeconnect.Helpers.StorageHelper; +import org.kde.kdeconnect.UserInterface.DeviceSettingsActivity; +import org.kde.kdeconnect.UserInterface.PluginSettingsFragment; +import org.kde.kdeconnect_tp.R; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.ListIterator; + +import androidx.annotation.NonNull; +import androidx.appcompat.view.ActionMode; +import androidx.fragment.app.Fragment; +import androidx.preference.Preference; +import androidx.preference.PreferenceCategory; +import androidx.preference.PreferenceManager; +import androidx.preference.PreferenceScreen; +import androidx.recyclerview.widget.RecyclerView; + +//TODO: Is it possible on API 19 to select a directory and then have write permission for everything beneath it +//TODO: Is it necessary to check if uri permissions are still in place? If it is make the user aware of the fact (red text or something) +public class SftpSettingsFragment + extends PluginSettingsFragment + implements StoragePreferenceDialogFragment.Callback, + Preference.OnPreferenceChangeListener, + StoragePreference.OnLongClickListener, ActionMode.Callback { + private final static String KEY_STORAGE_PREFERENCE_DIALOG = "StoragePreferenceDialog"; + private final static String KEY_ACTION_MODE_STATE = "ActionModeState"; + private final static String KEY_ACTION_MODE_ENABLED = "ActionModeEnabled"; + private final static String KEY_ACTION_MODE_SELECTED_ITEMS = "ActionModeSelectedItems"; + + private List storageInfoList; + private PreferenceCategory preferenceCategory; + private ActionMode actionMode; + private JSONObject savedActionModeState; + + public static SftpSettingsFragment newInstance(@NonNull String pluginKey) { + SftpSettingsFragment fragment = new SftpSettingsFragment(); + fragment.setArguments(pluginKey); + + return fragment; + } + + public SftpSettingsFragment() {} + + @Override + public void onCreate(Bundle savedInstanceState) { + // super.onCreate creates PreferenceManager and calls onCreatePreferences() + super.onCreate(savedInstanceState); + + if (getFragmentManager() != null) { + Fragment fragment = getFragmentManager().findFragmentByTag(KEY_STORAGE_PREFERENCE_DIALOG); + if (fragment != null) { + ((StoragePreferenceDialogFragment) fragment).setCallback(this); + } + } + + if (savedInstanceState != null && savedInstanceState.containsKey(KEY_ACTION_MODE_STATE)) { + try { + savedActionModeState = new JSONObject(savedInstanceState.getString(KEY_ACTION_MODE_STATE, "{}")); + } catch (JSONException ignored) {} + } + } + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + super.onCreatePreferences(savedInstanceState, rootKey); + + TypedArray ta = requireContext().obtainStyledAttributes(new int[]{R.attr.colorAccent}); + int colorAccent = ta.getColor(0, 0); + ta.recycle(); + + int sdkInt = Build.VERSION.SDK_INT; + + storageInfoList = getStorageInfoList(requireContext()); + + PreferenceScreen preferenceScreen = getPreferenceScreen(); + preferenceCategory = (PreferenceCategory) preferenceScreen + .findPreference(getString(R.string.sftp_preference_key_preference_category)); + + if (sdkInt <= 19) { + preferenceCategory.setTitle(R.string.sftp_preference_detected_sdcards); + } else { + preferenceCategory.setTitle(R.string.sftp_preference_configured_storage_locations); + } + + addStoragePreferences(preferenceCategory); + + Preference addStoragePreference = preferenceScreen.findPreference(getString(R.string.sftp_preference_key_add_storage)); + addStoragePreference.getIcon().setColorFilter(colorAccent, PorterDuff.Mode.SRC_IN); + + if (sdkInt <= 19) { + addStoragePreference.setVisible(false); + } + + Preference addCameraShortcutPreference = preferenceScreen.findPreference(getString(R.string.sftp_preference_key_add_camera_shortcut)); + + if (sdkInt > 19) { + addCameraShortcutPreference.setVisible(false); + } + } + + private void addStoragePreferences(PreferenceCategory preferenceCategory) { + /* + https://developer.android.com/guide/topics/ui/settings/programmatic-hierarchy + You can't just use any context to create Preferences, you have to use PreferenceManager's context + */ + Context context = getPreferenceManager().getContext(); + + sortStorageInfoListOnDisplayName(); + + for (int i = 0; i < storageInfoList.size(); i++) { + SftpPlugin.StorageInfo storageInfo = storageInfoList.get(i); + StoragePreference preference = new StoragePreference(context); + preference.setOnPreferenceChangeListener(this); + if (Build.VERSION.SDK_INT >= 21) { + preference.setOnLongClickListener(this); + } + preference.setKey(getString(R.string.sftp_preference_key_storage_info, i)); + preference.setIcon(android.R.color.transparent); + preference.setDefaultValue(storageInfo); + if (storageInfo.isFileUri()) { + preference.setDialogTitle(R.string.sftp_preference_edit_sdcard_title); + } else { + preference.setDialogTitle(R.string.sftp_preference_edit_storage_location); + } + + preferenceCategory.addPreference(preference); + } + } + + @Override + protected RecyclerView.Adapter onCreateAdapter(PreferenceScreen preferenceScreen) { + if (savedActionModeState != null) { + getListView().post(this::restoreActionMode); + } + + return super.onCreateAdapter(preferenceScreen); + } + + private void restoreActionMode() { + try { + if (savedActionModeState.getBoolean(KEY_ACTION_MODE_ENABLED)) { + actionMode = ((DeviceSettingsActivity)requireActivity()).startSupportActionMode(this); + + if (actionMode != null) { + JSONArray jsonArray = savedActionModeState.getJSONArray(KEY_ACTION_MODE_SELECTED_ITEMS); + SparseBooleanArray selectedItems = new SparseBooleanArray(); + + for (int i = 0, count = jsonArray.length(); i < count; i++) { + selectedItems.put(jsonArray.getInt(i), true); + } + + for (int i = 0, count = preferenceCategory.getPreferenceCount(); i < count; i++) { + StoragePreference preference = (StoragePreference) preferenceCategory.getPreference(i); + preference.setSelectable(true); + preference.checkbox.setChecked(selectedItems.get(i, false)); + } + } + } + + } catch (JSONException ignored) {} + } + + @Override + public void onDisplayPreferenceDialog(Preference preference) { + if (preference instanceof StoragePreference) { + StoragePreferenceDialogFragment fragment = StoragePreferenceDialogFragment.newInstance(preference.getKey()); + fragment.setTargetFragment(this, 0); + fragment.setCallback(this); + fragment.show(requireFragmentManager(), KEY_STORAGE_PREFERENCE_DIALOG); + } else { + super.onDisplayPreferenceDialog(preference); + } + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + + try { + JSONObject jsonObject = new JSONObject(); + + jsonObject.put(KEY_ACTION_MODE_ENABLED, actionMode != null); + + if (actionMode != null) { + JSONArray jsonArray = new JSONArray(); + + for (int i = 0, count = preferenceCategory.getPreferenceCount(); i < count; i++) { + StoragePreference preference = (StoragePreference) preferenceCategory.getPreference(i); + if (preference.checkbox.isChecked()) { + jsonArray.put(i); + } + } + + jsonObject.put(KEY_ACTION_MODE_SELECTED_ITEMS, jsonArray); + } + + outState.putString(KEY_ACTION_MODE_STATE, jsonObject.toString()); + } catch (JSONException ignored) {} + } + + private void saveStorageInfoList() { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()); + + JSONArray jsonArray = new JSONArray(); + + try { + for (SftpPlugin.StorageInfo storageInfo : storageInfoList) { + jsonArray.put(storageInfo.toJSON()); + } + } catch (JSONException ignored) {} + + preferences + .edit() + .putString(requireContext().getString(R.string.sftp_preference_key_storage_info_list), jsonArray.toString()) + .apply(); + } + + @NonNull + static List getStorageInfoList(@NonNull Context context) { + ArrayList storageInfoList = new ArrayList<>(); + + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); + + String jsonString = preferences + .getString(context.getString(R.string.sftp_preference_key_storage_info_list), "[]"); + + try { + JSONArray jsonArray = new JSONArray(jsonString); + + for (int i = 0; i < jsonArray.length(); i++) { + storageInfoList.add(SftpPlugin.StorageInfo.fromJSON(jsonArray.getJSONObject(i))); + } + } catch (JSONException e) { + e.printStackTrace(); + } + + if (Build.VERSION.SDK_INT <= 19) { + addDetectedSDCardsToStorageInfoList(context, storageInfoList); + } + + return storageInfoList; + } + + private static void addDetectedSDCardsToStorageInfoList(@NonNull Context context, List storageInfoList) { + List storageHelperInfoList = StorageHelper.getStorageList(); + + for (StorageHelper.StorageInfo info : storageHelperInfoList) { + // on at least API 17 emulator Environment.isExternalStorageRemovable returns false + if (info.removable || info.path.startsWith(Environment.getExternalStorageDirectory().getPath())) { + StringBuilder displayNameBuilder = new StringBuilder(); + StringBuilder displayNameReadOnlyBuilder = new StringBuilder(); + + Uri sdCardUri = Uri.fromFile(new File(info.path)); + + if (isAlreadyConfigured(storageInfoList, sdCardUri)) { + continue; + } + + int i = 1; + + do { + if (i == 1) { + displayNameBuilder.append(context.getString(R.string.sftp_sdcard)); + } else { + displayNameBuilder.setLength(0); + displayNameBuilder.append(context.getString(R.string.sftp_sdcard_num, i)); + } + + displayNameReadOnlyBuilder + .append(displayNameBuilder) + .append(" ") + .append(context.getString(R.string.sftp_readonly)); + + i++; + } while (!isDisplayNameUnique(storageInfoList, displayNameBuilder.toString(), displayNameReadOnlyBuilder.toString())); + + String displayName = (Build.VERSION.SDK_INT == 19 || info.readonly) ? + displayNameReadOnlyBuilder.toString() : displayNameBuilder.toString(); + + storageInfoList.add(new SftpPlugin.StorageInfo(displayName, Uri.fromFile(new File(info.path)))); + } + } + } + + private static boolean isDisplayNameUnique(List storageInfoList, String displayName, String displayNameReadOnly) { + for (SftpPlugin.StorageInfo info : storageInfoList) { + if (info.displayName.equals(displayName) || info.displayName.equals(displayName + displayNameReadOnly)) { + return false; + } + } + + return true; + } + + private static boolean isAlreadyConfigured(List storageInfoList, Uri sdCardUri) { + for (SftpPlugin.StorageInfo info : storageInfoList) { + if (info.uri.equals(sdCardUri)) { + return true; + } + } + + return false; + } + + private void sortStorageInfoListOnDisplayName() { + Collections.sort(storageInfoList, new SftpPlugin.StorageInfo.DisplayNameComparator()); + } + + @NonNull + @Override + public StoragePreferenceDialogFragment.CallbackResult isDisplayNameAllowed(@NonNull String displayName) { + StoragePreferenceDialogFragment.CallbackResult result = new StoragePreferenceDialogFragment.CallbackResult(); + result.isAllowed = true; + + if (displayName.isEmpty()) { + result.isAllowed = false; + result.errorMessage = getString(R.string.sftp_storage_preference_display_name_cannot_be_empty); + } else { + for (SftpPlugin.StorageInfo storageInfo : storageInfoList) { + if (storageInfo.displayName.equals(displayName)) { + result.isAllowed = false; + result.errorMessage = getString(R.string.sftp_storage_preference_display_name_already_used); + + break; + } + } + } + + return result; + } + + @NonNull + @Override + public StoragePreferenceDialogFragment.CallbackResult isUriAllowed(@NonNull Uri uri) { + StoragePreferenceDialogFragment.CallbackResult result = new StoragePreferenceDialogFragment.CallbackResult(); + result.isAllowed = true; + + for (SftpPlugin.StorageInfo storageInfo : storageInfoList) { + if (storageInfo.uri.equals(uri)) { + result.isAllowed = false; + result.errorMessage = getString(R.string.sftp_storage_preference_storage_location_already_configured); + + break; + } + } + return result; + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + @Override + public void addNewStoragePreference(@NonNull SftpPlugin.StorageInfo storageInfo, int takeFlags) { + storageInfoList.add(storageInfo); + + handleChangedStorageInfoList(); + + requireContext().getContentResolver().takePersistableUriPermission(storageInfo.uri, takeFlags); + } + + private void handleChangedStorageInfoList() { + saveStorageInfoList(); + + preferenceCategory.removeAll(); + + addStoragePreferences(preferenceCategory); + + Device device = BackgroundService.getInstance().getDevice(getDeviceId()); + + if (device != null) { + device.reloadPluginsFromSettings(); + } + } + + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + SftpPlugin.StorageInfo newStorageInfo = (SftpPlugin.StorageInfo) newValue; + + ListIterator it = storageInfoList.listIterator(); + + while (it.hasNext()) { + SftpPlugin.StorageInfo storageInfo = it.next(); + if (storageInfo.uri.equals(newStorageInfo.uri)) { + it.set(newStorageInfo); + break; + } + } + + handleChangedStorageInfoList(); + + return false; + } + + @Override + public void onLongClick(StoragePreference storagePreference) { + if (actionMode == null) { + actionMode = ((DeviceSettingsActivity)requireActivity()).startSupportActionMode(this); + + if (actionMode != null) { + for (int i = 0, count = preferenceCategory.getPreferenceCount(); i < count; i++) { + StoragePreference preference = (StoragePreference) preferenceCategory.getPreference(i); + preference.setSelectable(true); + if (storagePreference.equals(preference)) { + preference.checkbox.setChecked(true); + } + } + } + } + } + + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + MenuInflater inflater = mode.getMenuInflater(); + inflater.inflate(R.menu.sftp_settings_action_mode, menu); + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + switch (item.getItemId()) { + case R.id.delete: + for (int count = preferenceCategory.getPreferenceCount(), i = count - 1; i >= 0; i--) { + StoragePreference preference = (StoragePreference) preferenceCategory.getPreference(i); + if (preference.checkbox.isChecked()) { + SftpPlugin.StorageInfo info = storageInfoList.remove(i); + + if (Build.VERSION.SDK_INT >= 21) { + requireContext().getContentResolver().releasePersistableUriPermission(info.uri, Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + } + } + } + + actionMode.finish(); //This must be called before handleChangedStorageInfoList() + handleChangedStorageInfoList(); + return true; + default: + return false; + } + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + actionMode = null; + + for (int i = 0, count = preferenceCategory.getPreferenceCount(); i < count; i++) { + StoragePreference preference = (StoragePreference) preferenceCategory.getPreference(i); + preference.setSelectable(false); + preference.checkbox.setChecked(false); + } + } +} diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/SimpleSftpServer.java b/src/org/kde/kdeconnect/Plugins/SftpPlugin/SimpleSftpServer.java --- a/src/org/kde/kdeconnect/Plugins/SftpPlugin/SimpleSftpServer.java +++ b/src/org/kde/kdeconnect/Plugins/SftpPlugin/SimpleSftpServer.java @@ -21,15 +21,9 @@ package org.kde.kdeconnect.Plugins.SftpPlugin; import android.content.Context; -import android.net.Uri; import android.util.Log; import org.apache.sshd.SshServer; -import org.apache.sshd.common.Session; -import org.apache.sshd.common.file.FileSystemFactory; -import org.apache.sshd.common.file.FileSystemView; -import org.apache.sshd.common.file.nativefs.NativeFileSystemView; -import org.apache.sshd.common.file.nativefs.NativeSshFile; import org.apache.sshd.common.keyprovider.AbstractKeyPairProvider; import org.apache.sshd.common.util.SecurityUtils; import org.apache.sshd.server.PasswordAuthenticator; @@ -40,16 +34,10 @@ import org.apache.sshd.server.session.ServerSession; import org.apache.sshd.server.sftp.SftpSubsystem; import org.kde.kdeconnect.Device; -import org.kde.kdeconnect.Helpers.MediaStoreHelper; import org.kde.kdeconnect.Helpers.RandomHelper; import org.kde.kdeconnect.Helpers.SecurityHelpers.RsaHelper; import org.kde.kdeconnect.Helpers.SecurityHelpers.SslHelper; -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.io.RandomAccessFile; import java.net.Inet4Address; import java.net.InetAddress; import java.net.NetworkInterface; @@ -61,6 +49,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.Enumeration; +import java.util.List; class SimpleSftpServer { private static final int STARTPORT = 1739; @@ -80,6 +69,7 @@ } private final SshServer sshd = SshServer.setUpDefaultServer(); + private AndroidFileSystemFactory fileSystemFactory; void init(Context context, Device device) throws Exception { @@ -99,7 +89,8 @@ } }); - sshd.setFileSystemFactory(new AndroidFileSystemFactory(context)); + fileSystemFactory = new AndroidFileSystemFactory(context); + sshd.setFileSystemFactory(fileSystemFactory); sshd.setCommandFactory(new ScpCommandFactory()); sshd.setSubsystemFactories(Collections.singletonList(new SftpSubsystem.Factory())); @@ -112,9 +103,9 @@ sshd.setPasswordAuthenticator(passwordAuth); } - public boolean start() { + public boolean start(List storageInfoList) { if (!started) { - + fileSystemFactory.initRoots(storageInfoList); passwordAuth.password = RandomHelper.randomString(28); port = STARTPORT; @@ -147,6 +138,10 @@ } } + public boolean isStarted() { + return started; + } + String getPassword() { return passwordAuth.password; } @@ -188,120 +183,6 @@ return ip6; } - static class AndroidFileSystemFactory implements FileSystemFactory { - - final private Context context; - - AndroidFileSystemFactory(Context context) { - this.context = context; - } - - @Override - public FileSystemView createFileSystemView(final Session username) { - return new AndroidFileSystemView(username.getUsername(), context); - } - } - - static class AndroidFileSystemView extends NativeFileSystemView { - - final private Context context; - - AndroidFileSystemView(final String userName, Context context) { - super(userName, true); - this.context = context; - } - - // NativeFileSystemView.getFile(), NativeSshFile.getParentFile() and NativeSshFile.listSshFiles() call - // createNativeSshFile to create new NativeSshFiles so override that instead of getFile() to always create a AndroidSshFile - @Override - public AndroidSshFile createNativeSshFile(String name, File file, String username) { - return new AndroidSshFile(this, name, file, username, context); - } - } - - static class AndroidSshFile extends NativeSshFile { - - final private Context context; - final private File file; - - AndroidSshFile(final AndroidFileSystemView view, String name, final File file, final String userName, Context context) { - super(view, name, file, userName); - this.context = context; - this.file = file; - } - - @Override - public OutputStream createOutputStream(long offset) throws IOException { - if (!isWritable()) { - throw new IOException("No write permission : " + file.getName()); - } - - final RandomAccessFile raf = new RandomAccessFile(file, "rw"); - try { - if (offset < raf.length()) { - throw new 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 new FileOutputStream(raf.getFD()) { - public void close() throws IOException { - super.close(); - raf.close(); - } - }; - } catch (IOException e) { - raf.close(); - throw e; - } - } - - @Override - public boolean delete() { - //Log.e("Sshd", "deleting file"); - boolean ret = super.delete(); - if (ret) { - MediaStoreHelper.indexFile(context, Uri.fromFile(file)); - } - return ret; - - } - - @Override - public boolean create() throws IOException { - //Log.e("Sshd", "creating file"); - boolean ret = super.create(); - if (ret) { - MediaStoreHelper.indexFile(context, Uri.fromFile(file)); - } - return ret; - - } - - // Based on https://github.com/wolpi/prim-ftpd/blob/master/primitiveFTPd/src/org/primftpd/filesystem/FsFile.java - @Override - public boolean doesExist() { - boolean exists = file.exists(); - - if (!exists) { - // file.exists() returns false when we don't have read permission - // try to figure out if it really does not exist - File parentFile = file.getParentFile(); - File[] children = parentFile.listFiles(); - if (children != null) { - for (File child : children) { - if (file.equals(child)) { - exists = true; - break; - } - } - } - } - - return exists; - } - } - static class SimplePasswordAuthenticator implements PasswordAuthenticator { String password; diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/StoragePreference.java b/src/org/kde/kdeconnect/Plugins/SftpPlugin/StoragePreference.java new file mode 100644 --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/SftpPlugin/StoragePreference.java @@ -0,0 +1,135 @@ +/* + * Copyright 2018 Erik Duisters + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License or (at your option) version 3 or any later version + * accepted by the membership of KDE e.V. (or its successor approved + * by the membership of KDE e.V.), which shall act as a proxy + * defined in Section 14 of version 3 of the license. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kde.kdeconnect.Plugins.SftpPlugin; + +import android.content.Context; +import android.os.Build; +import android.provider.DocumentsContract; +import android.util.AttributeSet; +import android.view.View; +import android.widget.CheckBox; + +import org.kde.kdeconnect_tp.R; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.preference.DialogPreference; +import androidx.preference.PreferenceViewHolder; +import butterknife.BindView; +import butterknife.ButterKnife; + +public class StoragePreference extends DialogPreference { + @Nullable + private SftpPlugin.StorageInfo storageInfo; + @Nullable + private OnLongClickListener onLongClickListener; + + @BindView(R.id.checkbox) CheckBox checkbox; + + public StoragePreference(Context context, AttributeSet attrs) { + super(context, attrs); + + setDialogLayoutResource(R.layout.fragment_storage_preference_dialog); + setWidgetLayoutResource(R.layout.preference_checkbox); + setPersistent(false); + setSelectable(false); + } + + public StoragePreference(Context context) { + this(context, null); + } + + public void setOnLongClickListener(@Nullable OnLongClickListener listener) { + this.onLongClickListener = listener; + } + + public void setStorageInfo(@NonNull SftpPlugin.StorageInfo storageInfo) { + if (this.storageInfo != null && this.storageInfo.equals(storageInfo)) { + return; + } + + if (callChangeListener(storageInfo)) { + setStorageInfoInternal(storageInfo); + } + } + + private void setStorageInfoInternal(@NonNull SftpPlugin.StorageInfo storageInfo) { + this.storageInfo = storageInfo; + + setTitle(storageInfo.displayName); + if (Build.VERSION.SDK_INT < 21) { + setSummary(storageInfo.uri.getPath()); + } else { + setSummary(DocumentsContract.getTreeDocumentId(storageInfo.uri)); + } + } + + @Nullable + public SftpPlugin.StorageInfo getStorageInfo() { + return storageInfo; + } + + @Override + public void setDefaultValue(Object defaultValue) { + if (defaultValue == null || defaultValue instanceof SftpPlugin.StorageInfo) { + super.setDefaultValue(defaultValue); + } else { + throw new RuntimeException("StoragePreference defaultValue must be null or an instance of StfpPlugin.StorageInfo"); + } + } + + @Override + protected void onSetInitialValue(@Nullable Object defaultValue) { + if (defaultValue != null) { + setStorageInfoInternal((SftpPlugin.StorageInfo) defaultValue); + } + } + + @Override + public void onBindViewHolder(PreferenceViewHolder holder) { + super.onBindViewHolder(holder); + + ButterKnife.bind(this, holder.itemView); + + checkbox.setVisibility(isSelectable() ? View.VISIBLE : View.INVISIBLE); + + holder.itemView.setOnLongClickListener(v -> { + if (onLongClickListener != null) { + onLongClickListener.onLongClick(StoragePreference.this); + return true; + } + return false; + }); + } + + @Override + protected void onClick() { + if (isSelectable()) { + checkbox.setChecked(!checkbox.isChecked()); + } else { + super.onClick(); + } + } + + public interface OnLongClickListener { + void onLongClick(StoragePreference storagePreference); + } +} diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/StoragePreferenceDialogFragment.java b/src/org/kde/kdeconnect/Plugins/SftpPlugin/StoragePreferenceDialogFragment.java new file mode 100644 --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/SftpPlugin/StoragePreferenceDialogFragment.java @@ -0,0 +1,330 @@ +package org.kde.kdeconnect.Plugins.SftpPlugin; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.app.Dialog; +import android.content.Intent; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.provider.DocumentsContract; +import android.text.Editable; +import android.text.InputFilter; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.view.View; +import android.widget.Button; + +import com.google.android.material.textfield.TextInputEditText; +import com.google.android.material.textfield.TextInputLayout; + +import org.json.JSONException; +import org.json.JSONObject; +import org.kde.kdeconnect.Helpers.StorageHelper; +import org.kde.kdeconnect_tp.R; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.core.graphics.drawable.DrawableCompat; +import androidx.preference.PreferenceDialogFragmentCompat; +import butterknife.BindView; +import butterknife.ButterKnife; +import butterknife.OnClick; +import butterknife.Unbinder; + +public class StoragePreferenceDialogFragment extends PreferenceDialogFragmentCompat implements TextWatcher { + private static final int REQUEST_CODE_DOCUMENT_TREE = 1001; + + //When state is restored I cannot determine if an error is going to be displayed on one of the TextInputEditText's or not so I have to remember if the dialog's positive button was enabled or not + private static final String KEY_POSITIVE_BUTTON_ENABLED = "PositiveButtonEnabled"; + private static final String KEY_STORAGE_INFO = "StorageInfo"; + private static final String KEY_TAKE_FLAGS = "TakeFlags"; + + @BindView(R.id.storageLocation) TextInputEditText storageLocation; + @BindView(R.id.storageDisplayName) TextInputEditText storageDisplayName; + @BindView(R.id.storageDisplayNameInputLayout) TextInputLayout storageDisplayInputLayout; + + private Unbinder unbinder; + private Callback callback; + private Drawable arrowDropDownDrawable; + private Button positiveButton; + private boolean stateRestored; + private boolean enablePositiveButton; + private SftpPlugin.StorageInfo storageInfo; + private int takeFlags; + + public static StoragePreferenceDialogFragment newInstance(String key) { + StoragePreferenceDialogFragment fragment = new StoragePreferenceDialogFragment(); + + Bundle args = new Bundle(); + args.putString(ARG_KEY, key); + fragment.setArguments(args); + + return fragment; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + stateRestored = false; + enablePositiveButton = true; + + if (savedInstanceState != null) { + stateRestored = true; + enablePositiveButton = savedInstanceState.getBoolean(KEY_POSITIVE_BUTTON_ENABLED); + takeFlags = savedInstanceState.getInt(KEY_TAKE_FLAGS, 0); + try { + JSONObject jsonObject = new JSONObject(savedInstanceState.getString(KEY_STORAGE_INFO, "{}")); + storageInfo = SftpPlugin.StorageInfo.fromJSON(jsonObject); + } catch (JSONException ignored) {} + } + + Drawable drawable = AppCompatResources.getDrawable(requireContext(), R.drawable.ic_arrow_drop_down_24px); + if (drawable != null) { + drawable = DrawableCompat.wrap(drawable); + DrawableCompat.setTint(drawable, getResources().getColor(android.R.color.darker_gray)); + drawable.setBounds(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight()); + arrowDropDownDrawable = drawable; + } + } + + void setCallback(Callback callback) { + this.callback = callback; + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + AlertDialog dialog = (AlertDialog) super.onCreateDialog(savedInstanceState); + dialog.setOnShowListener(dialog1 -> { + AlertDialog alertDialog = (AlertDialog) dialog1; + positiveButton = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE); + positiveButton.setEnabled(enablePositiveButton); + }); + + return dialog; + } + + @Override + protected void onBindDialogView(View view) { + super.onBindDialogView(view); + + unbinder = ButterKnife.bind(this, view); + + storageDisplayName.setFilters(new InputFilter[]{new FileSeparatorCharFilter()}); + storageDisplayName.addTextChangedListener(this); + + if (getPreference().getKey().equals(getString(R.string.sftp_preference_key_add_storage))) { + if (!stateRestored) { + enablePositiveButton = false; + storageLocation.setText(requireContext().getString(R.string.sftp_storage_preference_click_to_select)); + } + + boolean isClickToSelect = storageLocation.getText() != null && storageLocation.getText().toString().equals(getString(R.string.sftp_storage_preference_click_to_select)); + + storageLocation.setCompoundDrawables(null, null, isClickToSelect ? arrowDropDownDrawable : null, null); + storageLocation.setEnabled(isClickToSelect); + storageLocation.setFocusable(isClickToSelect); + storageLocation.setFocusableInTouchMode(isClickToSelect); + + storageDisplayName.setEnabled(!isClickToSelect); + } else { + if (!stateRestored) { + StoragePreference preference = (StoragePreference) getPreference(); + SftpPlugin.StorageInfo info = preference.getStorageInfo(); + + if (info == null) { + throw new RuntimeException("Cannot edit a StoragePreference that does not have its storageInfo set"); + } + + storageInfo = SftpPlugin.StorageInfo.copy(info); + + if (Build.VERSION.SDK_INT < 21) { + storageLocation.setText(storageInfo.uri.getPath()); + } else { + storageLocation.setText(DocumentsContract.getTreeDocumentId(storageInfo.uri)); + } + + storageDisplayName.setText(storageInfo.displayName); + } + + storageLocation.setCompoundDrawables(null, null, null, null); + storageLocation.setEnabled(false); + storageLocation.setFocusable(false); + storageLocation.setFocusableInTouchMode(false); + + storageDisplayName.setEnabled(true); + } + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + + unbinder.unbind(); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + @OnClick(R.id.storageLocation) + void onSelectStorageClicked() { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + //For API >= 26 we can also set Extra: DocumentsContract.EXTRA_INITIAL_URI + startActivityForResult(intent, REQUEST_CODE_DOCUMENT_TREE); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (resultCode != Activity.RESULT_OK) { + return; + } + + switch (requestCode) { + case REQUEST_CODE_DOCUMENT_TREE: + Uri uri = data.getData(); + takeFlags = data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + + if (uri == null) { + return; + } + + CallbackResult result = callback.isUriAllowed(uri); + + if (result.isAllowed) { + String documentId = DocumentsContract.getTreeDocumentId(uri); + String displayName = StorageHelper.getDisplayName(requireContext(), uri); + + storageInfo = new SftpPlugin.StorageInfo(displayName, uri); + + storageLocation.setText(documentId); + storageLocation.setCompoundDrawables(null, null, null, null); + storageLocation.setError(null); + storageLocation.setEnabled(false); + + // TODO: Show name as used in android's picker app but I don't think it's possible to get that, everything I tried throws PermissionDeniedException + storageDisplayName.setText(displayName); + storageDisplayName.setEnabled(true); + } else { + storageLocation.setError(result.errorMessage); + setPositiveButtonEnabled(false); + } + break; + } + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + + outState.putBoolean(KEY_POSITIVE_BUTTON_ENABLED, positiveButton.isEnabled()); + outState.putInt(KEY_TAKE_FLAGS, takeFlags); + + if (storageInfo != null) { + try { + outState.putString(KEY_STORAGE_INFO, storageInfo.toJSON().toString()); + } catch (JSONException ignored) {} + } + } + + @Override + public void onDialogClosed(boolean positiveResult) { + if (positiveResult) { + storageInfo.displayName = storageDisplayName.getText().toString(); + + if (getPreference().getKey().equals(getString(R.string.sftp_preference_key_add_storage))) { + callback.addNewStoragePreference(storageInfo, takeFlags); + } else { + ((StoragePreference)getPreference()).setStorageInfo(storageInfo); + } + } + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + //Don't care + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + //Don't care + } + + @Override + public void afterTextChanged(Editable s) { + String displayName = s.toString(); + + StoragePreference storagePreference = (StoragePreference) getPreference(); + SftpPlugin.StorageInfo storageInfo = storagePreference.getStorageInfo(); + + if (storageInfo == null || !storageInfo.displayName.equals(displayName)) { + CallbackResult result = callback.isDisplayNameAllowed(displayName); + + if (result.isAllowed) { + setPositiveButtonEnabled(true); + } else { + setPositiveButtonEnabled(false); + storageDisplayName.setError(result.errorMessage); + } + } + } + + private void setPositiveButtonEnabled(boolean enabled) { + if (positiveButton != null) { + positiveButton.setEnabled(enabled); + } else { + enablePositiveButton = enabled; + } + } + + private class FileSeparatorCharFilter implements InputFilter { + //TODO: Add more chars to refuse? + //https://www.cyberciti.biz/faq/linuxunix-rules-for-naming-file-and-directory-names/ + String notAllowed = "/\\><|:&?*"; + + @Override + public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) { + boolean keepOriginal = true; + StringBuilder sb = new StringBuilder(end - start); + for (int i = start; i < end; i++) { + char c = source.charAt(i); + + if (notAllowed.indexOf(c) < 0) { + sb.append(c); + } else { + keepOriginal = false; + sb.append("_"); + } + } + + if (keepOriginal) { + return null; + } else { + if (source instanceof Spanned) { + SpannableString sp = new SpannableString(sb); + TextUtils.copySpansFrom((Spanned) source, start, sb.length(), null, sp, 0); + return sp; + } else { + return sb; + } + } + } + } + + static class CallbackResult { + boolean isAllowed; + String errorMessage; + } + + interface Callback { + @NonNull CallbackResult isDisplayNameAllowed(@NonNull String displayName); + @NonNull CallbackResult isUriAllowed(@NonNull Uri uri); + void addNewStoragePreference(@NonNull SftpPlugin.StorageInfo storageInfo, int takeFlags); + } +} diff --git a/src/org/kde/kdeconnect/UserInterface/DeviceSettingsActivity.java b/src/org/kde/kdeconnect/UserInterface/DeviceSettingsActivity.java --- a/src/org/kde/kdeconnect/UserInterface/DeviceSettingsActivity.java +++ b/src/org/kde/kdeconnect/UserInterface/DeviceSettingsActivity.java @@ -15,8 +15,8 @@ * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License - * along with this program. If not, see . -*/ + * along with this program. If not, see . + */ package org.kde.kdeconnect.UserInterface; @@ -131,4 +131,8 @@ public void onFinish() { finish(); } + + public String getDeviceId() { + return deviceId; + } } diff --git a/src/org/kde/kdeconnect/UserInterface/DeviceSettingsFragment.java b/src/org/kde/kdeconnect/UserInterface/DeviceSettingsFragment.java --- a/src/org/kde/kdeconnect/UserInterface/DeviceSettingsFragment.java +++ b/src/org/kde/kdeconnect/UserInterface/DeviceSettingsFragment.java @@ -100,6 +100,7 @@ List plugins = device.getSupportedPlugins(); for (final String pluginKey : plugins) { + //TODO: Use PreferenceManagers context PluginPreference pref = new PluginPreference(requireContext(), pluginKey, device, callback); preferenceScreen.addPreference(pref); } diff --git a/src/org/kde/kdeconnect/UserInterface/PluginSettingsFragment.java b/src/org/kde/kdeconnect/UserInterface/PluginSettingsFragment.java --- a/src/org/kde/kdeconnect/UserInterface/PluginSettingsFragment.java +++ b/src/org/kde/kdeconnect/UserInterface/PluginSettingsFragment.java @@ -78,4 +78,8 @@ PluginFactory.PluginInfo info = PluginFactory.getPluginInfo(requireContext(), pluginKey); requireActivity().setTitle(getString(R.string.plugin_settings_with_name, info.getDisplayName())); } + + public String getDeviceId() { + return ((DeviceSettingsActivity)requireActivity()).getDeviceId(); + } }