diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/AndroidSshFile.java b/src/org/kde/kdeconnect/Plugins/SftpPlugin/AndroidSshFile.java deleted file mode 100644 index 31e8acaf..00000000 --- a/src/org/kde/kdeconnect/Plugins/SftpPlugin/AndroidSshFile.java +++ /dev/null @@ -1,98 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2018 Erik Duisters - * - * 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; - -class AndroidSshFile extends NativeSshFile { - private static final 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 - try { - exists = FileUtils.directoryContains(file.getParentFile(), file); - } catch (IOException | IllegalArgumentException e) { - // An IllegalArgumentException is thrown if the parent is null or not a directory. - Log.d(TAG, "Exception: ", e); - } - } - - return exists; - } -} diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/AndroidSshFile.kt b/src/org/kde/kdeconnect/Plugins/SftpPlugin/AndroidSshFile.kt new file mode 100644 index 00000000..4d0192d0 --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/SftpPlugin/AndroidSshFile.kt @@ -0,0 +1,90 @@ +/* + * SPDX-FileCopyrightText: 2018 Erik Duisters + * + * 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 + } +} diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/DHG14_256.java b/src/org/kde/kdeconnect/Plugins/SftpPlugin/DHG14_256.java deleted file mode 100644 index e0b863f4..00000000 --- a/src/org/kde/kdeconnect/Plugins/SftpPlugin/DHG14_256.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 Albert Vaca Cintora - * - * 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; - -public class DHG14_256 extends AbstractDHGServer { - - public static class Factory implements NamedFactory { - - public String getName() { - return "diffie-hellman-group14-sha256"; - } - - public KeyExchange create() { - return new DHG14_256(); - } - - } - - @Override - protected AbstractDH getDH() throws Exception { - DH dh = new DH(new SHA256.Factory()); - dh.setG(DHGroupData.getG()); - dh.setP(DHGroupData.getP14()); - return dh; - } - -} diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/DHG14_256.kt b/src/org/kde/kdeconnect/Plugins/SftpPlugin/DHG14_256.kt new file mode 100644 index 00000000..39002155 --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/SftpPlugin/DHG14_256.kt @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2023 Albert Vaca Cintora + * + * 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 { + 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()) + } + } +} diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/RootFile.java b/src/org/kde/kdeconnect/Plugins/SftpPlugin/RootFile.java deleted file mode 100644 index 22d87c39..00000000 --- a/src/org/kde/kdeconnect/Plugins/SftpPlugin/RootFile.java +++ /dev/null @@ -1,163 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2018 Erik Duisters - * - * 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.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) { - 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 ""; - } - - 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/RootFile.kt b/src/org/kde/kdeconnect/Plugins/SftpPlugin/RootFile.kt new file mode 100644 index 00000000..ea729214 --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/SftpPlugin/RootFile.kt @@ -0,0 +1,105 @@ +/* + * SPDX-FileCopyrightText: 2018 Erik Duisters + * + * 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, + private val userName: String, + private val exists: Boolean +) : SshFile { + override fun getAbsolutePath(): String = "/" + + override fun getName(): String = "/" + + override fun getAttributes(followLinks: Boolean): Map { + val attrs: MutableMap = 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) {} + + 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 = Collections.unmodifiableList(files) + + override fun createOutputStream(offset: Long): OutputStream? = null + + override fun createInputStream(offset: Long): InputStream? = null + + override fun handleClose() { + } +} diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/SftpPlugin.java b/src/org/kde/kdeconnect/Plugins/SftpPlugin/SftpPlugin.java deleted file mode 100644 index 23d43e02..00000000 --- a/src/org/kde/kdeconnect/Plugins/SftpPlugin/SftpPlugin.java +++ /dev/null @@ -1,326 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2014 Samoilenko Yuri - * - * 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.app.Activity; -import android.content.ContentResolver; -import android.content.SharedPreferences; -import android.net.Uri; -import android.os.Build; -import android.os.Environment; -import android.os.storage.StorageManager; -import android.os.storage.StorageVolume; -import android.provider.Settings; - -import androidx.annotation.NonNull; - -import org.json.JSONException; -import org.json.JSONObject; -import org.kde.kdeconnect.Helpers.NetworkHelper; -import org.kde.kdeconnect.NetworkPacket; -import org.kde.kdeconnect.Plugins.Plugin; -import org.kde.kdeconnect.Plugins.PluginFactory; -import org.kde.kdeconnect.UserInterface.AlertDialogFragment; -import org.kde.kdeconnect.UserInterface.DeviceSettingsAlertDialogFragment; -import org.kde.kdeconnect.UserInterface.MainActivity; -import org.kde.kdeconnect.UserInterface.PluginSettingsFragment; -import org.kde.kdeconnect.UserInterface.StartActivityAlertDialogFragment; -import org.kde.kdeconnect_tp.BuildConfig; -import org.kde.kdeconnect_tp.R; - -import java.security.GeneralSecurityException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; -import java.util.Iterator; -import java.util.List; -import java.util.Objects; - -@PluginFactory.LoadablePlugin -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"; - - static final int PREFERENCE_KEY_STORAGE_INFO_LIST = R.string.sftp_preference_key_storage_info_list; - - private static final SimpleSftpServer server = new SimpleSftpServer(); - - @Override - public @NonNull String getDisplayName() { - return context.getResources().getString(R.string.pref_plugin_sftp); - } - - @Override - public @NonNull String getDescription() { - return context.getResources().getString(R.string.pref_plugin_sftp_desc); - } - - @Override - public boolean onCreate() { - return true; - } - - @Override - public boolean checkRequiredPermissions() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - return Environment.isExternalStorageManager(); - } else { - return SftpSettingsFragment.getStorageInfoList(context, this).size() != 0; - } - } - - @Override - public @NonNull AlertDialogFragment getPermissionExplanationDialog() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - return new StartActivityAlertDialogFragment.Builder() - .setTitle(getDisplayName()) - .setMessage(R.string.sftp_manage_storage_permission_explanation) - .setPositiveButton(R.string.open_settings) - .setNegativeButton(R.string.cancel) - .setIntentAction(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) - .setIntentUrl("package:" + BuildConfig.APPLICATION_ID) - .setStartForResult(true) - .setRequestCode(MainActivity.RESULT_NEEDS_RELOAD) - .create(); - } else { - return new DeviceSettingsAlertDialogFragment.Builder() - .setTitle(getDisplayName()) - .setMessage(R.string.sftp_saf_permission_explanation) - .setPositiveButton(R.string.ok) - .setNegativeButton(R.string.cancel) - .setDeviceId(getDevice().getDeviceId()) - .setPluginKey(getPluginKey()) - .create(); - } - } - - @Override - public void onDestroy() { - server.stop(); - if (getPreferences() != null) { - getPreferences().unregisterOnSharedPreferenceChangeListener(this); - } - } - - @Override - public boolean onPacketReceived(@NonNull NetworkPacket np) { - if (np.getBoolean("startBrowsing")) { - if (!server.isInitialized()) { - try { - server.initialize(context, getDevice()); - } catch (GeneralSecurityException e) { - throw new RuntimeException(e); - } - } - - ArrayList paths = new ArrayList<>(); - ArrayList pathNames = new ArrayList<>(); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - List volumes = context.getSystemService(StorageManager.class).getStorageVolumes(); - for (StorageVolume sv : volumes) { - pathNames.add(sv.getDescription(context)); - paths.add(sv.getDirectory().getPath()); - } - } else { - List storageInfoList = SftpSettingsFragment.getStorageInfoList(context, this); - Collections.sort(storageInfoList, Comparator.comparing(StorageInfo::getUri)); - if (storageInfoList.size() > 0) { - getPathsAndNamesForStorageInfoList(paths, pathNames, storageInfoList); - } else { - NetworkPacket np2 = new NetworkPacket(PACKET_TYPE_SFTP); - np2.set("errorMessage", context.getString(R.string.sftp_no_storage_locations_configured)); - getDevice().sendPacket(np2); - return true; - } - removeChildren(storageInfoList); - server.setSafRoots(storageInfoList); - } - - if (server.start()) { - if (getPreferences() != null) { - getPreferences().registerOnSharedPreferenceChangeListener(this); - } - - NetworkPacket np2 = new NetworkPacket(PACKET_TYPE_SFTP); - - np2.set("ip", NetworkHelper.getLocalIpAddress().getHostAddress()); - np2.set("port", server.getPort()); - np2.set("user", SimpleSftpServer.USER); - np2.set("password", server.regeneratePassword()); - - //Kept for compatibility, in case "multiPaths" is not possible or the other end does not support it - if (paths.size() == 1) { - np2.set("path", paths.get(0)); - } else { - np2.set("path", "/"); - } - - if (paths.size() > 0) { - np2.set("multiPaths", paths); - np2.set("pathNames", pathNames); - } - - getDevice().sendPacket(np2); - - return true; - } - } - return false; - } - - private void getPathsAndNamesForStorageInfoList(List paths, List pathNames, List storageInfoList) { - StorageInfo prevInfo = null; - StringBuilder pathBuilder = new StringBuilder(); - - 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); - } - } - - 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; - } - } - } - } - - @Override - public @NonNull String[] getSupportedPacketTypes() { - return new String[]{PACKET_TYPE_SFTP_REQUEST}; - } - - @Override - public @NonNull String[] getOutgoingPacketTypes() { - return new String[]{PACKET_TYPE_SFTP}; - } - - @Override - public boolean hasSettings() { - return Build.VERSION.SDK_INT < Build.VERSION_CODES.R; - } - - @Override - public boolean supportsDeviceSpecificSettings() { return true; } - - @Override - public PluginSettingsFragment getSettingsFragment(Activity activity) { - return SftpSettingsFragment.newInstance(getPluginKey(), R.xml.sftpplugin_preferences); - } - - @Override - public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { - if (key.equals(context.getString(PREFERENCE_KEY_STORAGE_INFO_LIST))) { - 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 - final Uri uri; - - StorageInfo(@NonNull String displayName, @NonNull Uri uri) { - this.displayName = displayName; - this.uri = uri; - } - - @NonNull - Uri getUri() { - return 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; - } - } -} diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/SftpPlugin.kt b/src/org/kde/kdeconnect/Plugins/SftpPlugin/SftpPlugin.kt new file mode 100644 index 00000000..9847a1b8 --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/SftpPlugin/SftpPlugin.kt @@ -0,0 +1,258 @@ +/* + * SPDX-FileCopyrightText: 2014 Samoilenko Yuri + * + * 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.app.Activity +import android.content.ContentResolver +import android.content.SharedPreferences +import android.content.SharedPreferences.OnSharedPreferenceChangeListener +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.os.storage.StorageManager +import android.provider.Settings +import org.json.JSONException +import org.json.JSONObject +import org.kde.kdeconnect.Helpers.NetworkHelper.localIpAddress +import org.kde.kdeconnect.NetworkPacket +import org.kde.kdeconnect.Plugins.Plugin +import org.kde.kdeconnect.Plugins.PluginFactory.LoadablePlugin +import org.kde.kdeconnect.UserInterface.AlertDialogFragment +import org.kde.kdeconnect.UserInterface.DeviceSettingsAlertDialogFragment +import org.kde.kdeconnect.UserInterface.MainActivity +import org.kde.kdeconnect.UserInterface.PluginSettingsFragment +import org.kde.kdeconnect.UserInterface.StartActivityAlertDialogFragment +import org.kde.kdeconnect_tp.BuildConfig +import org.kde.kdeconnect_tp.R +import java.security.GeneralSecurityException + +@LoadablePlugin +class SftpPlugin : Plugin(), OnSharedPreferenceChangeListener { + override val displayName: String + get() = context.resources.getString(R.string.pref_plugin_sftp) + + override val description: String + get() = context.resources.getString(R.string.pref_plugin_sftp_desc) + + override fun onCreate(): Boolean = true + + override fun checkRequiredPermissions(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Environment.isExternalStorageManager() + } else { + SftpSettingsFragment.getStorageInfoList(context, this).size != 0 + } + } + + override val permissionExplanationDialog: AlertDialogFragment + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + StartActivityAlertDialogFragment.Builder() + .setTitle(displayName) + .setMessage(R.string.sftp_manage_storage_permission_explanation) + .setPositiveButton(R.string.open_settings) + .setNegativeButton(R.string.cancel) + .setIntentAction(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) + .setIntentUrl("package:" + BuildConfig.APPLICATION_ID) + .setStartForResult(true) + .setRequestCode(MainActivity.RESULT_NEEDS_RELOAD) + .create() + } else { + DeviceSettingsAlertDialogFragment.Builder() + .setTitle(displayName) + .setMessage(R.string.sftp_saf_permission_explanation) + .setPositiveButton(R.string.ok) + .setNegativeButton(R.string.cancel) + .setDeviceId(device.deviceId) + .setPluginKey(pluginKey) + .create() + } + + override fun onDestroy() { + server.stop() + preferences?.unregisterOnSharedPreferenceChangeListener(this) + } + + override fun onPacketReceived(np: NetworkPacket): Boolean { + if (!np.getBoolean("startBrowsing")) return false + + if (!server.isInitialized) { + try { + server.initialize(context, device) + } catch (e: GeneralSecurityException) { + throw RuntimeException(e) + } + } + + val paths = mutableListOf() + val pathNames = mutableListOf() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val volumes = context.getSystemService( + StorageManager::class.java + ).storageVolumes + for (sv in volumes) { + pathNames.add(sv.getDescription(context)) + paths.add(sv.directory!!.path) + } + } else { + val storageInfoList = SftpSettingsFragment.getStorageInfoList(context, this) + storageInfoList.sortBy { it.uri } + if (storageInfoList.size <= 0) { + device.sendPacket(NetworkPacket(PACKET_TYPE_SFTP).apply { + this["errorMessage"] = context.getString(R.string.sftp_no_storage_locations_configured) + }) + return true + } + getPathsAndNamesForStorageInfoList(paths, pathNames, storageInfoList) + storageInfoList.removeChildren() + server.setSafRoots(storageInfoList) + } + + if (!server.start()) { + return false + } + + if (preferences != null) { + preferences!!.registerOnSharedPreferenceChangeListener(this) + } + + device.sendPacket(NetworkPacket(PACKET_TYPE_SFTP).apply { + this["ip"] = localIpAddress!!.hostAddress + this["port"] = server.port + this["user"] = SimpleSftpServer.USER + this["password"] = server.regeneratePassword() + // Kept for compatibility, in case "multiPaths" is not possible or the other end does not support it + this["path"] = if (paths.size == 1) paths[0] else "/" + if (paths.size > 0) { + this["multiPaths"] = paths + this["pathNames"] = pathNames + } + }) + + return true + } + + private fun getPathsAndNamesForStorageInfoList( + paths: MutableList, + pathNames: MutableList, + storageInfoList: List + ) { + var prevInfo: StorageInfo? = null + val pathBuilder = StringBuilder() + + for (curInfo in 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.path != null && prevInfo.uri.path != null) { + pathBuilder.append(curInfo.uri.path!!.substring(prevInfo.uri.path!!.length)) + } else { + throw 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) + } + } + + private fun MutableList.removeChildren() { + fun StorageInfo.isParentOf(other: StorageInfo): Boolean = + other.uri.toString().startsWith(this.uri.toString()) + + var currentParent: StorageInfo? = null + + retainAll { curInfo -> + when { + currentParent == null -> { + currentParent = curInfo + true + } + + currentParent!!.isParentOf(curInfo) -> { + false + } + + else -> { + currentParent = curInfo + true + } + } + } + } + + override val supportedPacketTypes: Array = arrayOf(PACKET_TYPE_SFTP_REQUEST) + + override val outgoingPacketTypes: Array = arrayOf(PACKET_TYPE_SFTP) + + override fun hasSettings(): Boolean = Build.VERSION.SDK_INT < Build.VERSION_CODES.R + + override fun supportsDeviceSpecificSettings(): Boolean = true + + override fun getSettingsFragment(activity: Activity): PluginSettingsFragment { + return SftpSettingsFragment.newInstance(pluginKey, R.xml.sftpplugin_preferences) + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) { + if (key != context.getString(PREFERENCE_KEY_STORAGE_INFO_LIST)) return + if (!server.isStarted) return + + server.stop() + + val np = NetworkPacket(PACKET_TYPE_SFTP_REQUEST).apply { + this["startBrowsing"] = true + } + onPacketReceived(np) + } + + data class StorageInfo(@JvmField var displayName: String, @JvmField val uri: Uri) { + val isFileUri: Boolean = uri.scheme == ContentResolver.SCHEME_FILE + val isContentUri: Boolean = uri.scheme == ContentResolver.SCHEME_CONTENT + + @Throws(JSONException::class) + fun toJSON(): JSONObject { + return JSONObject().apply { + put(KEY_DISPLAY_NAME, displayName) + put(KEY_URI, uri.toString()) + } + } + + companion object { + private const val KEY_DISPLAY_NAME = "DisplayName" + private const val KEY_URI = "Uri" + + @JvmStatic + @Throws(JSONException::class) + fun fromJSON(jsonObject: JSONObject): StorageInfo { // TODO: Use Result after migrate callee to Kotlin + val displayName = jsonObject.getString(KEY_DISPLAY_NAME) + val uri = Uri.parse(jsonObject.getString(KEY_URI)) + + return StorageInfo(displayName, uri) + } + } + } + + companion object { + private const val PACKET_TYPE_SFTP = "kdeconnect.sftp" + private const val PACKET_TYPE_SFTP_REQUEST = "kdeconnect.sftp.request" + + @JvmField + val PREFERENCE_KEY_STORAGE_INFO_LIST: Int = R.string.sftp_preference_key_storage_info_list + + private val server = SimpleSftpServer() + } +} diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/SignatureRSASHA256.java b/src/org/kde/kdeconnect/Plugins/SftpPlugin/SignatureRSASHA256.java deleted file mode 100644 index f7f13f80..00000000 --- a/src/org/kde/kdeconnect/Plugins/SftpPlugin/SignatureRSASHA256.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 Albert Vaca Cintora - * - * 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; - -public class SignatureRSASHA256 extends AbstractSignature { - - public static class Factory implements NamedFactory { - - public String getName() { - return "rsa-sha2-256"; - } - - public Signature create() { - return new SignatureRSASHA256(); - } - - } - - public SignatureRSASHA256() { - super("SHA256withRSA"); - } - - public byte[] sign() throws Exception { - return signature.sign(); - } - - public boolean verify(byte[] sig) throws Exception { - sig = extractSig(sig); - return signature.verify(sig); - } - -} diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/SignatureRSASHA256.kt b/src/org/kde/kdeconnect/Plugins/SftpPlugin/SignatureRSASHA256.kt new file mode 100644 index 00000000..7fb7119e --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/SftpPlugin/SignatureRSASHA256.kt @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: 2023 Albert Vaca Cintora + * + * 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 + +class SignatureRSASHA256 : AbstractSignature("SHA256withRSA") { + class Factory : NamedFactory { + override fun getName(): String = "rsa-sha2-256" + + override fun create(): Signature { + return SignatureRSASHA256() + } + } + + @Throws(Exception::class) + override fun sign(): ByteArray { + return signature.sign() + } + + @Throws(Exception::class) + override fun verify(sig: ByteArray): Boolean { + return signature.verify(extractSig(sig)) + } +} diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/SimpleSftpServer.java b/src/org/kde/kdeconnect/Plugins/SftpPlugin/SimpleSftpServer.java deleted file mode 100644 index 3d0c864b..00000000 --- a/src/org/kde/kdeconnect/Plugins/SftpPlugin/SimpleSftpServer.java +++ /dev/null @@ -1,206 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2014 Samoilenko Yuri - * - * 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.os.Build; -import android.util.Log; - -import org.apache.sshd.SshServer; -import org.apache.sshd.common.file.nativefs.NativeFileSystemFactory; -import org.apache.sshd.common.keyprovider.AbstractKeyPairProvider; -import org.apache.sshd.common.signature.SignatureDSA; -import org.apache.sshd.common.signature.SignatureECDSA; -import org.apache.sshd.common.signature.SignatureRSA; -import org.apache.sshd.common.util.SecurityUtils; -import org.apache.sshd.server.PasswordAuthenticator; -import org.apache.sshd.server.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.session.ServerSession; -import org.apache.sshd.server.sftp.SftpSubsystem; -import org.kde.kdeconnect.Device; -import org.kde.kdeconnect.Helpers.RandomHelper; -import org.kde.kdeconnect.Helpers.SecurityHelpers.RsaHelper; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.security.GeneralSecurityException; -import java.security.KeyPair; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.security.PrivateKey; -import java.security.PublicKey; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -import static org.kde.kdeconnect.Helpers.SecurityHelpers.ConstantTimeCompareKt.constantTimeCompare; - -class SimpleSftpServer { - private static final int STARTPORT = 1739; - private static final int ENDPORT = 1764; - - static final String USER = "kdeconnect"; - - private int port = -1; - private boolean started = false; - - private final SimplePasswordAuthenticator passwordAuth = new SimplePasswordAuthenticator(); - private final SimplePublicKeyAuthenticator keyAuth = new SimplePublicKeyAuthenticator(); - - static { - SecurityUtils.setRegisterBouncyCastle(false); - } - - boolean initialized = false; - - private final SshServer sshd = SshServer.setUpDefaultServer(); - private AndroidFileSystemFactory safFileSystemFactory; - - public void setSafRoots(List storageInfoList) { - safFileSystemFactory.initRoots(storageInfoList); - } - - void initialize(Context context, Device device) throws GeneralSecurityException { - - sshd.setSignatureFactories(Arrays.asList( - new SignatureECDSA.NISTP256Factory(), - new SignatureECDSA.NISTP384Factory(), - new SignatureECDSA.NISTP521Factory(), - new SignatureDSA.Factory(), - new SignatureRSASHA256.Factory(), - new SignatureRSA.Factory() // Insecure SHA1, left for backwards compatibility - )); - - sshd.setKeyExchangeFactories(Arrays.asList( - new ECDHP256.Factory(), // ecdh-sha2-nistp256 - new ECDHP384.Factory(), // ecdh-sha2-nistp384 - new ECDHP521.Factory(), // ecdh-sha2-nistp521 - new DHG14_256.Factory(), // diffie-hellman-group14-sha256 - new DHG14.Factory() // Insecure diffie-hellman-group14-sha1, left for backwards-compatibility. - )); - - //Reuse this device keys for the ssh connection as well - final KeyPair keyPair; - PrivateKey privateKey = RsaHelper.getPrivateKey(context); - PublicKey publicKey = RsaHelper.getPublicKey(context); - keyPair = new KeyPair(publicKey, privateKey); - sshd.setKeyPairProvider(new AbstractKeyPairProvider() { - @Override - public Iterable loadKeys() { - return Collections.singletonList(keyPair); - } - }); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - sshd.setFileSystemFactory(new NativeFileSystemFactory()); - } else { - safFileSystemFactory = new AndroidFileSystemFactory(context); - sshd.setFileSystemFactory(safFileSystemFactory); - } - sshd.setCommandFactory(new ScpCommandFactory()); - sshd.setSubsystemFactories(Collections.singletonList(new SftpSubsystem.Factory())); - - keyAuth.deviceKey = device.getCertificate().getPublicKey(); - - sshd.setPublickeyAuthenticator(keyAuth); - sshd.setPasswordAuthenticator(passwordAuth); - - initialized = true; - } - - public boolean start() { - if (!started) { - regeneratePassword(); - - port = STARTPORT; - while (!started) { - try { - sshd.setPort(port); - sshd.start(); - started = true; - } catch (IOException e) { - port++; - if (port >= ENDPORT) { - port = -1; - Log.e("SftpServer", "No more ports available"); - return false; - } - } - } - } - - return true; - } - - public void stop() { - try { - started = false; - sshd.stop(true); - } catch (Exception e) { - Log.e("SFTP", "Exception while stopping the server", e); - } - } - - public boolean isStarted() { - return started; - } - - String regeneratePassword() { - String password = RandomHelper.randomString(28); - passwordAuth.setPassword(password); - return password; - } - - int getPort() { - return port; - } - - public boolean isInitialized() { - return initialized; - } - - static class SimplePasswordAuthenticator implements PasswordAuthenticator { - - MessageDigest sha; - { - try { - sha = MessageDigest.getInstance("SHA-256"); - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException(e); - } - } - - public void setPassword(String password) { - sha.digest(password.getBytes(StandardCharsets.UTF_8)); - } - - byte[] passwordHash; - - @Override - public boolean authenticate(String user, String password, ServerSession session) { - byte[] receivedPasswordHash = sha.digest(password.getBytes(StandardCharsets.UTF_8)); - return user.equals(SimpleSftpServer.USER) && constantTimeCompare(passwordHash, receivedPasswordHash); - } - } - - static class SimplePublicKeyAuthenticator implements PublickeyAuthenticator { - - PublicKey deviceKey; - - @Override - public boolean authenticate(String user, PublicKey key, ServerSession session) { - return deviceKey.equals(key); - } - - } - -} diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/SimpleSftpServer.kt b/src/org/kde/kdeconnect/Plugins/SftpPlugin/SimpleSftpServer.kt new file mode 100644 index 00000000..5ec84962 --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/SftpPlugin/SimpleSftpServer.kt @@ -0,0 +1,189 @@ +/* + * SPDX-FileCopyrightText: 2014 Samoilenko Yuri + * + * 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.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.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.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.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.session.ServerSession +import org.apache.sshd.server.sftp.SftpSubsystem +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 java.io.IOException +import java.nio.charset.StandardCharsets +import java.security.GeneralSecurityException +import java.security.KeyPair +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException +import java.security.PublicKey + +internal class SimpleSftpServer { + var port: Int = -1 + private set + var isStarted: Boolean = false + private set + + private val passwordAuth = SimplePasswordAuthenticator() + private val keyAuth = SimplePublicKeyAuthenticator() + + var isInitialized: Boolean = false + + private val sshd: SshServer = SshServer.setUpDefaultServer() + + private var safFileSystemFactory: AndroidFileSystemFactory? = null + + fun setSafRoots(storageInfoList: List) { + safFileSystemFactory!!.initRoots(storageInfoList) + } + + @Throws(GeneralSecurityException::class) + fun initialize(context: Context?, device: Device) { + sshd.signatureFactories = + listOf( + NISTP256Factory(), + NISTP384Factory(), + NISTP521Factory(), + SignatureDSA.Factory(), + SignatureRSASHA256.Factory(), + SignatureRSA.Factory() // 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. + ) + + // Reuse this device keys for the ssh connection as well + val keyPair = KeyPair( + RsaHelper.getPublicKey(context), + RsaHelper.getPrivateKey(context) + ) + sshd.keyPairProvider = object : AbstractKeyPairProvider() { + override fun loadKeys(): Iterable = listOf(keyPair) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + sshd.fileSystemFactory = NativeFileSystemFactory() + } else { + safFileSystemFactory = AndroidFileSystemFactory(context) + sshd.fileSystemFactory = safFileSystemFactory + } + sshd.commandFactory = ScpCommandFactory() + sshd.subsystemFactories = + listOf>(SftpSubsystem.Factory()) + + keyAuth.deviceKey = device.certificate.publicKey + + sshd.publickeyAuthenticator = keyAuth + sshd.passwordAuthenticator = passwordAuth + + this.isInitialized = true + } + + fun start(): Boolean { + if (!isStarted) { + regeneratePassword() + + port = STARTPORT + while (!isStarted) { + try { + sshd.port = port + sshd.start() + isStarted = true + } catch (e: IOException) { + port++ + if (port >= ENDPORT) { + port = -1 + Log.e("SftpServer", "No more ports available") + return false + } + } + } + } + + return true + } + + fun stop() { + try { + isStarted = false + sshd.stop(true) + } catch (e: Exception) { + Log.e("SFTP", "Exception while stopping the server", e) + } + } + + fun regeneratePassword(): String { + val password = RandomHelper.randomString(28) + passwordAuth.setPassword(password) + return password + } + + internal class SimplePasswordAuthenticator : PasswordAuthenticator { + private var sha: MessageDigest? = null + + init { + try { + sha = MessageDigest.getInstance("SHA-256") + } catch (e: NoSuchAlgorithmException) { + throw RuntimeException(e) + } + } + + fun setPassword(password: String) { + sha!!.digest(password.toByteArray(StandardCharsets.UTF_8)) + } + + var passwordHash: ByteArray = byteArrayOf() + + override fun authenticate(user: String, password: String, session: ServerSession): Boolean { + val receivedPasswordHash = sha!!.digest(password.toByteArray(StandardCharsets.UTF_8)) + return user == USER && constantTimeCompare(passwordHash, receivedPasswordHash) + } + } + + internal class SimplePublicKeyAuthenticator : PublickeyAuthenticator { + var deviceKey: PublicKey? = null + + override fun authenticate(user: String, key: PublicKey, session: ServerSession): Boolean = + deviceKey == key + } + + companion object { + private const val STARTPORT = 1739 + private const val ENDPORT = 1764 + + const val USER: String = "kdeconnect" + + init { + SecurityUtils.setRegisterBouncyCastle(false) + } + } +} diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/StoragePreference.java b/src/org/kde/kdeconnect/Plugins/SftpPlugin/StoragePreference.java deleted file mode 100644 index 0e42279e..00000000 --- a/src/org/kde/kdeconnect/Plugins/SftpPlugin/StoragePreference.java +++ /dev/null @@ -1,122 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2018 Erik Duisters - * - * 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.provider.DocumentsContract; -import android.util.AttributeSet; -import android.view.View; -import android.widget.CheckBox; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.preference.DialogPreference; -import androidx.preference.PreferenceViewHolder; - -import org.kde.kdeconnect_tp.R; - -public class StoragePreference extends DialogPreference { - @Nullable - private SftpPlugin.StorageInfo storageInfo; - @Nullable - private OnLongClickListener onLongClickListener; - - CheckBox checkbox; - public boolean inSelectionMode; - - public void setInSelectionMode(boolean inSelectionMode) { - if (this.inSelectionMode != inSelectionMode) { - this.inSelectionMode = inSelectionMode; - notifyChanged(); - } - } - - public StoragePreference(Context context, AttributeSet attrs) { - super(context, attrs); - - setDialogLayoutResource(R.layout.fragment_storage_preference_dialog); - setWidgetLayoutResource(R.layout.preference_checkbox); - setPersistent(false); - inSelectionMode = 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); - 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(@NonNull PreferenceViewHolder holder) { - super.onBindViewHolder(holder); - - checkbox = (CheckBox) holder.itemView.findViewById(R.id.checkbox); - - checkbox.setVisibility(inSelectionMode ? View.VISIBLE : View.INVISIBLE); - - holder.itemView.setOnLongClickListener(v -> { - if (onLongClickListener != null) { - onLongClickListener.onLongClick(StoragePreference.this); - return true; - } - return false; - }); - } - - @Override - protected void onClick() { - if (inSelectionMode) { - checkbox.setChecked(!checkbox.isChecked()); - } else { - super.onClick(); - } - } - - public interface OnLongClickListener { - void onLongClick(StoragePreference storagePreference); - } -} diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/StoragePreference.kt b/src/org/kde/kdeconnect/Plugins/SftpPlugin/StoragePreference.kt new file mode 100644 index 00000000..746c454d --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/SftpPlugin/StoragePreference.kt @@ -0,0 +1,100 @@ +/* + * SPDX-FileCopyrightText: 2018 Erik Duisters + * + * 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.provider.DocumentsContract +import android.util.AttributeSet +import android.view.View +import android.widget.CheckBox +import androidx.preference.DialogPreference +import androidx.preference.PreferenceViewHolder +import org.kde.kdeconnect_tp.R + +class StoragePreference @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + DialogPreference( + context, attrs + ) { + var storageInfo: SftpPlugin.StorageInfo? = null + private set + private var onLongClickListener: OnLongClickListener? = null + + lateinit var checkbox: CheckBox + var inSelectionMode: Boolean = false + set(value) { + if (field != value) { + field = value + notifyChanged() + } + } + + init { + dialogLayoutResource = R.layout.fragment_storage_preference_dialog + widgetLayoutResource = R.layout.preference_checkbox + isPersistent = false + } + + fun setOnLongClickListener(listener: OnLongClickListener?) { + this.onLongClickListener = listener + } + + fun setStorageInfo(storageInfo: SftpPlugin.StorageInfo) { + if (this.storageInfo != null && (this.storageInfo == storageInfo)) { + return + } + + if (callChangeListener(storageInfo)) { + setStorageInfoInternal(storageInfo) + } + } + + private fun setStorageInfoInternal(storageInfo: SftpPlugin.StorageInfo) { + this.storageInfo = storageInfo + + title = storageInfo.displayName + summary = DocumentsContract.getTreeDocumentId(storageInfo.uri) + } + + override fun setDefaultValue(defaultValue: Any?) { + require(defaultValue == null || defaultValue is SftpPlugin.StorageInfo) { + "StoragePreference defaultValue must be null or an instance of StfpPlugin.StorageInfo" + } + super.setDefaultValue(defaultValue) + } + + override fun onSetInitialValue(defaultValue: Any?) { + if (defaultValue != null) { + setStorageInfoInternal(defaultValue as SftpPlugin.StorageInfo) + } + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + + checkbox = holder.itemView.findViewById(R.id.checkbox) as CheckBox + checkbox.visibility = if (inSelectionMode) View.VISIBLE else View.INVISIBLE + + holder.itemView.setOnLongClickListener { + onLongClickListener?.let { + it.onLongClick(this@StoragePreference) + true + } ?: false + } + } + + override fun onClick() { + if (inSelectionMode) { + checkbox.isChecked = !checkbox.isChecked + return + } + + super.onClick() + } + + interface OnLongClickListener { + fun onLongClick(storagePreference: StoragePreference) + } +} diff --git a/src/org/kde/kdeconnect/Plugins/SftpPlugin/StoragePreferenceDialogFragment.java b/src/org/kde/kdeconnect/Plugins/SftpPlugin/StoragePreferenceDialogFragment.java deleted file mode 100644 index 282e2065..00000000 --- a/src/org/kde/kdeconnect/Plugins/SftpPlugin/StoragePreferenceDialogFragment.java +++ /dev/null @@ -1,323 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2019 Erik Duisters - * - * 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.app.Activity; -import android.app.Dialog; -import android.content.Intent; -import android.graphics.drawable.Drawable; -import android.net.Uri; -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 androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.content.res.AppCompatResources; -import androidx.core.content.ContextCompat; -import androidx.core.graphics.drawable.DrawableCompat; -import androidx.core.widget.TextViewCompat; -import androidx.preference.PreferenceDialogFragmentCompat; - -import org.json.JSONException; -import org.json.JSONObject; -import org.kde.kdeconnect.Helpers.StorageHelper; -import org.kde.kdeconnect_tp.R; -import org.kde.kdeconnect_tp.databinding.FragmentStoragePreferenceDialogBinding; - -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"; - - private FragmentStoragePreferenceDialogBinding binding; - - 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, ContextCompat.getColor(requireContext(), - 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(@NonNull View view) { - super.onBindDialogView(view); - - binding = FragmentStoragePreferenceDialogBinding.bind(view); - - binding.storageLocation.setOnClickListener(v -> { - 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); - }); - - binding.storageDisplayName.setFilters(new InputFilter[]{new FileSeparatorCharFilter()}); - binding.storageDisplayName.addTextChangedListener(this); - - if (getPreference().getKey().equals(getString(R.string.sftp_preference_key_add_storage))) { - if (!stateRestored) { - enablePositiveButton = false; - binding.storageLocation.setText(requireContext().getString(R.string.sftp_storage_preference_click_to_select)); - } - - boolean isClickToSelect = TextUtils.equals(binding.storageLocation.getText(), - getString(R.string.sftp_storage_preference_click_to_select)); - - TextViewCompat.setCompoundDrawablesRelative(binding.storageLocation, null, null, - isClickToSelect ? arrowDropDownDrawable : null, null); - binding.storageLocation.setEnabled(isClickToSelect); - binding.storageLocation.setFocusable(false); - binding.storageLocation.setFocusableInTouchMode(false); - - binding.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); - - binding.storageLocation.setText(DocumentsContract.getTreeDocumentId(storageInfo.uri)); - - binding.storageDisplayName.setText(storageInfo.displayName); - } - - TextViewCompat.setCompoundDrawablesRelative(binding.storageLocation, null, null, null, null); - binding.storageLocation.setEnabled(false); - binding.storageLocation.setFocusable(false); - binding.storageLocation.setFocusableInTouchMode(false); - - binding.storageDisplayName.setEnabled(true); - } - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - - binding = null; - } - - @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); - - binding.storageLocation.setText(documentId); - TextViewCompat.setCompoundDrawablesRelative(binding.storageLocation, null, null, null, null); - binding.storageLocation.setError(null); - binding.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 - binding.storageDisplayName.setText(displayName); - binding.storageDisplayName.setEnabled(true); - } else { - binding.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 = binding.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); - binding.storageDisplayName.setError(result.errorMessage); - } - } - } - - private void setPositiveButtonEnabled(boolean enabled) { - if (positiveButton != null) { - positiveButton.setEnabled(enabled); - } else { - enablePositiveButton = enabled; - } - } - - private static 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/Plugins/SftpPlugin/StoragePreferenceDialogFragment.kt b/src/org/kde/kdeconnect/Plugins/SftpPlugin/StoragePreferenceDialogFragment.kt new file mode 100644 index 00000000..9a275a2d --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/SftpPlugin/StoragePreferenceDialogFragment.kt @@ -0,0 +1,338 @@ +/* + * SPDX-FileCopyrightText: 2019 Erik Duisters + * + * 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.app.Activity +import android.app.Dialog +import android.content.DialogInterface +import android.content.Intent +import android.graphics.drawable.Drawable +import android.net.Uri +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 androidx.appcompat.app.AlertDialog +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.DrawableCompat +import androidx.core.widget.TextViewCompat +import androidx.preference.PreferenceDialogFragmentCompat +import org.json.JSONException +import org.json.JSONObject +import org.kde.kdeconnect.Helpers.StorageHelper +import org.kde.kdeconnect.Plugins.SftpPlugin.SftpPlugin.StorageInfo.Companion.fromJSON +import org.kde.kdeconnect_tp.R +import org.kde.kdeconnect_tp.databinding.FragmentStoragePreferenceDialogBinding + +class StoragePreferenceDialogFragment : PreferenceDialogFragmentCompat(), TextWatcher { + private var binding: FragmentStoragePreferenceDialogBinding? = null + + var callback: Callback? = null + private var arrowDropDownDrawable: Drawable? = null + private var positiveButton: Button? = null + private var stateRestored = false + private var enablePositiveButton = false + private var storageInfo: SftpPlugin.StorageInfo? = null + private var takeFlags = 0 + + override fun onCreate(savedInstanceState: Bundle?) { + 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 { + val jsonObject = JSONObject(savedInstanceState.getString(KEY_STORAGE_INFO, "{}")) + storageInfo = fromJSON(jsonObject) + } catch (ignored: JSONException) { + } + } + + var drawable = + AppCompatResources.getDrawable(requireContext(), R.drawable.ic_arrow_drop_down_24px) + if (drawable != null) { + drawable = DrawableCompat.wrap(drawable) + DrawableCompat.setTint( + drawable, ContextCompat.getColor( + requireContext(), + android.R.color.darker_gray + ) + ) + drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight) + arrowDropDownDrawable = drawable + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialog = super.onCreateDialog(savedInstanceState) as AlertDialog + dialog.setOnShowListener { alertDialog: DialogInterface -> + positiveButton = (alertDialog as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE).apply { + isEnabled = enablePositiveButton + } + } + + return dialog + } + + override fun onBindDialogView(view: View) { + super.onBindDialogView(view) + + val binding = FragmentStoragePreferenceDialogBinding.bind(view).also { + this.binding = it + } + + binding.storageLocation.setOnClickListener { v: View? -> + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + // For API >= 26 we can also set Extra: DocumentsContract.EXTRA_INITIAL_URI + startActivityForResult(intent, REQUEST_CODE_DOCUMENT_TREE) + } + + binding.storageDisplayName.filters = arrayOf(FileSeparatorCharFilter()) + binding.storageDisplayName.addTextChangedListener(this) + + if (preference.key == getString(R.string.sftp_preference_key_add_storage)) { + if (!stateRestored) { + enablePositiveButton = false + binding.storageLocation.setText(requireContext().getString(R.string.sftp_storage_preference_click_to_select)) + } + + val isClickToSelect = TextUtils.equals( + binding.storageLocation.text, + getString(R.string.sftp_storage_preference_click_to_select) + ) + + TextViewCompat.setCompoundDrawablesRelative( + binding.storageLocation, null, null, + if (isClickToSelect) arrowDropDownDrawable else null, null + ) + binding.storageLocation.isEnabled = isClickToSelect + binding.storageLocation.isFocusable = false + binding.storageLocation.isFocusableInTouchMode = false + + binding.storageDisplayName.isEnabled = !isClickToSelect + } else { + if (!stateRestored) { + val preference = preference as StoragePreference + val info = preference.storageInfo + ?: throw RuntimeException("Cannot edit a StoragePreference that does not have its storageInfo set") + + storageInfo = info.copy() + + binding.storageLocation.setText(DocumentsContract.getTreeDocumentId(storageInfo!!.uri)) + + binding.storageDisplayName.setText(storageInfo!!.displayName) + } + + TextViewCompat.setCompoundDrawablesRelative( + binding.storageLocation, + null, + null, + null, + null + ) + binding.storageLocation.isEnabled = false + binding.storageLocation.isFocusable = false + binding.storageLocation.isFocusableInTouchMode = false + + binding.storageDisplayName.isEnabled = true + } + } + + override fun onDestroyView() { + super.onDestroyView() + + binding = null + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + + if (resultCode != Activity.RESULT_OK) { + return + } + + when (requestCode) { + REQUEST_CODE_DOCUMENT_TREE -> { + val uri = data!!.data + takeFlags = + data.flags and (Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + + if (uri == null) { + return + } + + val result = callback!!.isUriAllowed(uri) + + if (result.isAllowed) { + val documentId = DocumentsContract.getTreeDocumentId(uri) + val displayName = StorageHelper.getDisplayName(requireContext(), uri) + + storageInfo = SftpPlugin.StorageInfo(displayName, uri) + + binding!!.storageLocation.setText(documentId) + TextViewCompat.setCompoundDrawablesRelative( + binding!!.storageLocation, + null, + null, + null, + null + ) + binding!!.storageLocation.error = null + binding!!.storageLocation.isEnabled = 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 + binding!!.storageDisplayName.setText(displayName) + binding!!.storageDisplayName.isEnabled = true + } else { + binding!!.storageLocation.error = result.errorMessage + setPositiveButtonEnabled(false) + } + } + } + } + + override fun onSaveInstanceState(outState: Bundle) { + 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 (ignored: JSONException) { + } + } + } + + override fun onDialogClosed(positiveResult: Boolean) { + if (!positiveResult) return + + storageInfo!!.displayName = binding!!.storageDisplayName.text.toString() + + if (preference.key == getString(R.string.sftp_preference_key_add_storage)) { + callback!!.addNewStoragePreference(storageInfo!!, takeFlags) + } else { + (preference as StoragePreference).setStorageInfo(storageInfo!!) + } + } + + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { + // Don't care + } + + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + // Don't care + } + + override fun afterTextChanged(s: Editable) { + val displayName = s.toString() + + val storagePreference = preference as StoragePreference + val storageInfo = storagePreference.storageInfo + + if (storageInfo != null && storageInfo.displayName == displayName) return + + val result = callback!!.isDisplayNameAllowed(displayName) + + if (result.isAllowed) { + setPositiveButtonEnabled(true) + } else { + setPositiveButtonEnabled(false) + binding!!.storageDisplayName.error = result.errorMessage + } + } + + private fun setPositiveButtonEnabled(enabled: Boolean) { + if (positiveButton != null) { + positiveButton!!.isEnabled = enabled + } else { + enablePositiveButton = enabled + } + } + + private class FileSeparatorCharFilter : InputFilter { + // TODO: Add more chars to refuse? + // https://www.cyberciti.biz/faq/linuxunix-rules-for-naming-file-and-directory-names/ + var notAllowed: String = "/\\><|:&?*" + + override fun filter( + source: CharSequence, + start: Int, + end: Int, + dest: Spanned, + dstart: Int, + dend: Int + ): CharSequence? { + var keepOriginal = true + val sb = StringBuilder(end - start) + for (i in start until end) { + val c = source[i] + + if (notAllowed.indexOf(c) < 0) { + sb.append(c) + } else { + keepOriginal = false + sb.append("_") + } + } + + if (keepOriginal) { + return null + } else { + if (source is Spanned) { + val sp = SpannableString(sb) + TextUtils.copySpansFrom(source, start, sb.length, null, sp, 0) + return sp + } else { + return sb + } + } + } + } + + class CallbackResult { + @JvmField + var isAllowed: Boolean = false + @JvmField + var errorMessage: String? = null + } + + interface Callback { + fun isDisplayNameAllowed(displayName: String): CallbackResult + fun isUriAllowed(uri: Uri): CallbackResult + fun addNewStoragePreference(storageInfo: SftpPlugin.StorageInfo, takeFlags: Int) + } + + companion object { + private const val 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 const val KEY_POSITIVE_BUTTON_ENABLED = "PositiveButtonEnabled" + private const val KEY_STORAGE_INFO = "StorageInfo" + private const val KEY_TAKE_FLAGS = "TakeFlags" + + @JvmStatic + fun newInstance(key: String): StoragePreferenceDialogFragment { + return StoragePreferenceDialogFragment().apply { + arguments = Bundle().apply { + putString(ARG_KEY, key) + } + } + } + } +}