2
0
mirror of https://github.com/KDE/kdeconnect-android synced 2025-08-30 21:55:10 +00:00

refactor: migrate classes to Kotlin

refactor: migrate `AndroidSshFile` to Kotlin
refactor: migrate `DHG14_256` to Kotlin
refactor: migrate `RootFile` to Kotlin
refactor: migrate `SftpPlugin` to Kotlin
refactor: migrate `SignatureRSASHA256` to Kotlin
refactor: migrate `SimpleSftpServer` to Kotlin
refactor: migrate `StoragePreference` to Kotlin
refactor: migrate `StoragePreferenceDialogFragment` to Kotlin
This commit is contained in:
ShellWen Chen
2024-06-12 00:08:02 +08:00
committed by Albert Vaca Cintora
parent 6d78fe749a
commit 6783f0a167
16 changed files with 1142 additions and 1317 deletions

View File

@@ -1,98 +0,0 @@
/*
* SPDX-FileCopyrightText: 2018 Erik Duisters <e.duisters1@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin;
import android.content.Context;
import android.net.Uri;
import android.util.Log;
import org.apache.commons.io.FileUtils;
import org.apache.sshd.common.file.nativefs.NativeSshFile;
import org.kde.kdeconnect.Helpers.MediaStoreHelper;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.RandomAccessFile;
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;
}
}

View File

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

View File

@@ -1,39 +0,0 @@
/*
* SPDX-FileCopyrightText: 2023 Albert Vaca Cintora <albertvaka@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin;
import org.apache.sshd.common.KeyExchange;
import org.apache.sshd.common.NamedFactory;
import org.apache.sshd.common.digest.SHA256;
import org.apache.sshd.common.kex.AbstractDH;
import org.apache.sshd.common.kex.DH;
import org.apache.sshd.common.kex.DHGroupData;
import org.apache.sshd.server.kex.AbstractDHGServer;
public class DHG14_256 extends AbstractDHGServer {
public static class Factory implements NamedFactory<KeyExchange> {
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;
}
}

View File

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

View File

@@ -1,163 +0,0 @@
/*
* SPDX-FileCopyrightText: 2018 Erik Duisters <e.duisters1@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin;
import org.apache.sshd.common.file.SshFile;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Calendar;
import java.util.Collections;
import java.util.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<SshFile> files;
RootFile(List<SshFile> files, String userName, boolean exits) {
this.files = files;
this.userName = userName;
this.exists = exits;
}
public String getAbsolutePath() {
return "/";
}
public String getName() {
return "/";
}
public Map<Attribute, Object> getAttributes(boolean followLinks) {
Map<Attribute, Object> attrs = new HashMap<>();
attrs.put(Attribute.Size, 0);
attrs.put(Attribute.Owner, userName);
attrs.put(Attribute.Group, userName);
EnumSet<Permission> 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<Attribute, Object> 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<SshFile> listSshFiles() {
return Collections.unmodifiableList(files);
}
public OutputStream createOutputStream(long offset) {
return null;
}
public InputStream createInputStream(long offset) {
return null;
}
public void handleClose() {
}
}

View File

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

View File

@@ -1,326 +0,0 @@
/*
* SPDX-FileCopyrightText: 2014 Samoilenko Yuri <kinnalru@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin;
import android.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<String> paths = new ArrayList<>();
ArrayList<String> pathNames = new ArrayList<>();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
List<StorageVolume> volumes = context.getSystemService(StorageManager.class).getStorageVolumes();
for (StorageVolume sv : volumes) {
pathNames.add(sv.getDescription(context));
paths.add(sv.getDirectory().getPath());
}
} else {
List<StorageInfo> 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<String> paths, List<String> pathNames, List<StorageInfo> 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<StorageInfo> storageInfoList) {
StorageInfo prevInfo = null;
Iterator<StorageInfo> 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;
}
}
}

View File

@@ -0,0 +1,258 @@
/*
* SPDX-FileCopyrightText: 2014 Samoilenko Yuri <kinnalru@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin
import android.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<String>()
val pathNames = mutableListOf<String>()
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<String>,
pathNames: MutableList<String>,
storageInfoList: List<StorageInfo>
) {
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<StorageInfo>.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<String> = arrayOf(PACKET_TYPE_SFTP_REQUEST)
override val outgoingPacketTypes: Array<String> = 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()
}
}

View File

@@ -1,40 +0,0 @@
/*
* SPDX-FileCopyrightText: 2023 Albert Vaca Cintora <albertvaka@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin;
import org.apache.sshd.common.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<Signature> {
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);
}
}

View File

@@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: 2023 Albert Vaca Cintora <albertvaka@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin
import org.apache.sshd.common.NamedFactory
import org.apache.sshd.common.Signature
import org.apache.sshd.common.signature.AbstractSignature
class SignatureRSASHA256 : AbstractSignature("SHA256withRSA") {
class Factory : NamedFactory<Signature> {
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))
}
}

View File

@@ -1,206 +0,0 @@
/*
* SPDX-FileCopyrightText: 2014 Samoilenko Yuri <kinnalru@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin;
import android.content.Context;
import android.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<SftpPlugin.StorageInfo> 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<KeyPair> 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);
}
}
}

View File

@@ -0,0 +1,189 @@
/*
* SPDX-FileCopyrightText: 2014 Samoilenko Yuri <kinnalru@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin
import android.content.Context
import android.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<SftpPlugin.StorageInfo>) {
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<KeyPair> = 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<NamedFactory<Command>>(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)
}
}
}

View File

@@ -1,122 +0,0 @@
/*
* SPDX-FileCopyrightText: 2018 Erik Duisters <e.duisters1@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin;
import android.content.Context;
import android.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);
}
}

View File

@@ -0,0 +1,100 @@
/*
* SPDX-FileCopyrightText: 2018 Erik Duisters <e.duisters1@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin
import android.content.Context
import android.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<View>(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)
}
}

View File

@@ -1,323 +0,0 @@
/*
* SPDX-FileCopyrightText: 2019 Erik Duisters <e.duisters1@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin;
import android.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);
}
}

View File

@@ -0,0 +1,338 @@
/*
* SPDX-FileCopyrightText: 2019 Erik Duisters <e.duisters1@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SftpPlugin
import android.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<InputFilter>(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)
}
}
}
}
}