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:
committed by
Albert Vaca Cintora
parent
6d78fe749a
commit
6783f0a167
@@ -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;
|
||||
}
|
||||
}
|
90
src/org/kde/kdeconnect/Plugins/SftpPlugin/AndroidSshFile.kt
Normal file
90
src/org/kde/kdeconnect/Plugins/SftpPlugin/AndroidSshFile.kt
Normal 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
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
|
||||
}
|
32
src/org/kde/kdeconnect/Plugins/SftpPlugin/DHG14_256.kt
Normal file
32
src/org/kde/kdeconnect/Plugins/SftpPlugin/DHG14_256.kt
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
@@ -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() {
|
||||
}
|
||||
}
|
105
src/org/kde/kdeconnect/Plugins/SftpPlugin/RootFile.kt
Normal file
105
src/org/kde/kdeconnect/Plugins/SftpPlugin/RootFile.kt
Normal 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() {
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
258
src/org/kde/kdeconnect/Plugins/SftpPlugin/SftpPlugin.kt
Normal file
258
src/org/kde/kdeconnect/Plugins/SftpPlugin/SftpPlugin.kt
Normal 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()
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
|
||||
}
|
@@ -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))
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
189
src/org/kde/kdeconnect/Plugins/SftpPlugin/SimpleSftpServer.kt
Normal file
189
src/org/kde/kdeconnect/Plugins/SftpPlugin/SimpleSftpServer.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
100
src/org/kde/kdeconnect/Plugins/SftpPlugin/StoragePreference.kt
Normal file
100
src/org/kde/kdeconnect/Plugins/SftpPlugin/StoragePreference.kt
Normal 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)
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user