2
0
mirror of https://github.com/KDE/kdeconnect-android synced 2025-08-22 09:58:08 +00:00

Use Storage Access Framework on SDK >= 21 (Lollipop and above)

Summary:
Use Storage Access Framework on Android running SDK >= 21 so writing to
sdcard will work again

|{F6546802}|{F6546803}|{F6546804}|
|API 21+|API 19-|Edit|

Test Plan:
Install patch on Android phone with Build.Version < 19 (Kitkat)

- Without a sdcard: Verify that dolphin displays an "All Files" entry that is empty
- With a sdcard and with "Add camera folder shortcut" turned off: Verify that dolphin displays the configured display name of the sdcard
- With a sdcard and with "Add camera folder shortcut" turned on: Verify that dolphin displays the configured display name of the sdcard and also lists a "Camera pictures" shortcut
- With a sdcard: Verify that when changing the display name or the "Add camera folder shortcut" preference dolphin displays the updated items (after pressing F5)
- With a sdcard: Verify that files can be read and written to/from the sdcard

Install patch on Android phone with Build.Version < 19 (Kitkat)
- Repeat the above tests except for the read/write test: Verify that files can be read from the sdcard

Install patch on Android phone with Build.Version > 21 (Lollipop)

- Without any configured storage locations: Verify dolphin displays an "All Files" entry that is empty
- With configured storage locations: Verify dolphin displays the display names of the configured storage locations and that entering a location displays the correct directory entries
- Make one or several changes to the configured storage locations: Verify dolphin displays the display names of the configured storage locations (after pressing F5) and that entering a location displays the correct directory entries

Reviewers: #kde_connect, albertvaka, sredman

Reviewed By: #kde_connect, albertvaka, sredman

Subscribers: albertvaka, sredman, kdeconnect

Tags: #kde_connect

Differential Revision: https://phabricator.kde.org/D18212
This commit is contained in:
Erik Duisters 2019-03-08 13:44:54 +01:00
parent f48b5612c7
commit a6fdddf843
26 changed files with 2600 additions and 199 deletions

View File

@ -65,6 +65,11 @@ dependencies {
repositories {
jcenter()
google()
/* Needed for org.apache.sshd debugging
maven {
url "https://jitpack.io"
}
*/
}
implementation 'androidx.media:media:1.0.1'
@ -77,6 +82,7 @@ dependencies {
implementation 'org.apache.sshd:sshd-core:0.14.0'
implementation 'org.apache.mina:mina-core:2.0.19' //For some reason, makes sshd-core:0.14.0 work without NIO, which isn't available until Android 8+
//implementation('com.github.bright:slf4android:0.1.6') { transitive = true } // For org.apache.sshd debugging
implementation 'com.madgag.spongycastle:bcpkix-jdk15on:1.58.0.0' //For SSL certificate generation
implementation 'com.jakewharton:butterknife:10.0.0'

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M7,10l5,5 5,-5z"/>
</vector>

View File

@ -1,9 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"/>
<path android:fillColor="#FFF" android:pathData="M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z" />
</vector>

View File

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingLeft="?attr/dialogPreferredPadding"
android:paddingRight="?attr/dialogPreferredPadding"
android:paddingTop="10dp">
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.FilledBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="5dp"
app:errorEnabled="true">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/storageLocation"
style="@style/Widget.MaterialComponents.TextInputEditText.FilledBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:cursorVisible="false"
android:hint="@string/sftp_storage_preference_storage_location"
android:lines="1"
android:longClickable="false"
android:maxLines="1"
android:scrollHorizontally="true"
android:ellipsize="end"
android:inputType="text"
android:text="@string/sftp_storage_preference_click_to_select"
android:textColor="@android:color/darker_gray"
android:editable="false"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/storageDisplayNameInputLayout"
style="@style/Widget.MaterialComponents.TextInputLayout.FilledBox.Dense"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="5dp"
app:errorEnabled="true">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/storageDisplayName"
style="@style/Widget.MaterialComponents.TextInputEditText.FilledBox.Dense"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/sftp_storage_preference_display_name"
android:lines="1"
android:maxLines="1"
android:scrollHorizontally="true"
android:ellipsize="end"
android:inputType="text"/>
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<CheckBox
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/checkbox"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:focusable="false"
android:clickable="false"
android:background="@null"/>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/delete"
android:title="@string/sftp_action_mode_menu_delete"
app:showAsAction="ifRoom"
android:icon="@drawable/ic_delete"/>
</menu>

View File

@ -225,13 +225,37 @@
<string name="title_activity_notification_filter">Notification filter</string>
<string name="filter_apps_info">Notifications will be synchronized for the selected apps.</string>
<string name="sftp_internal_storage">Internal storage</string>
<string name="sftp_all_files">All files</string>
<string name="sftp_sdcard_num">SD card %d</string>
<string name="sftp_sdcard">SD card</string>
<string name="sftp_readonly">(read only)</string>
<string name="sftp_camera">Camera pictures</string>
<string name="add_device_dialog_title">Add device</string>
<string name="add_device_hint">Hostname or IP address</string>
<string name="sftp_preference_detected_sdcards">Detected SD cards</string>
<string name="sftp_preference_edit_sdcard_title">Edit SD card</string>
<string name="sftp_preference_configured_storage_locations">Configured storage locations</string>
<string name="sftp_preference_add_storage_location_title">Add storage location</string>
<string name="sftp_preference_edit_storage_location">Edit storage location</string>
<string name="sftp_preference_add_camera_shortcut">Add camera folder shortcut</string>
<string name="sftp_preference_add_camera_shortcut_summary_on">Add a shortcut to the camera folder</string>
<string name="sftp_preference_add_camera_shortcut_summary_off">Do not add a shortcut to the camera folder</string>
<string name="sftp_preference_key_preference_category" translatable="false">key_sftp_preference_category</string>
<string name="sftp_preference_key_add_storage" translatable="false">key_sftp_add_storage</string>
<string name="sftp_preference_key_add_camera_shortcut" translatable="false">key_sftp_add_camera_shotcut</string>
<string name="sftp_preference_key_storage_info" translatable="false">key_sftp_storage_info%d"</string>
<string name="sftp_preference_key_storage_info_list" translatable="false">key_sftp_storage_info_list</string>
<string name="sftp_storage_preference_storage_location">Storage location</string>
<string name="sftp_storage_preference_storage_location_already_configured">This location has already been configured</string>
<string name="sftp_storage_preference_click_to_select">click to select</string>
<string name="sftp_storage_preference_display_name">Display name</string>
<string name="sftp_storage_preference_display_name_already_used">This display name is already used</string>
<string name="sftp_storage_preference_display_name_cannot_be_empty">Display name cannot be empty</string>
<string name="sftp_action_mode_menu_delete">Delete</string>
<string name="sftp_no_sdcard_detected">No SD card detected</string>
<string name="sftp_no_storage_locations_configured">No storage locations configured</string>
<string name="sftp_saf_permission_explanation">To access files remotely you have to configure storage locations</string>
<string name="add_host">Add host/IP</string>
<string name="add_host_hint">Hostname or IP</string>
<string name="no_players_connected">No players found</string>
<string name="mpris_player_on_device">%1$s on %2$s</string>
<string name="send_files">Send files</string>
@ -262,7 +286,6 @@
<string name="permission_explanation">This plugin needs permissions to work</string>
<string name="optional_permission_explanation">You need to grant extra permissions to enable all functions</string>
<string name="plugins_need_optional_permission">Some plugins have features disabled because of lack of permission (tap for more info):</string>
<string name="sftp_permission_explanation">To access your files from your PC the app needs permission to access your phone\'s storage</string>
<string name="share_optional_permission_explanation">To share files between your phone and your desktop you need to give access to the phone\'s storage</string>
<string name="telepathy_permission_explanation">To read and write SMS from your desktop you need to give permission to SMS</string>
<string name="telephony_permission_explanation">To see phone calls and SMS from the desktop you need to give permission to phone calls and SMS</string>

View File

@ -18,6 +18,7 @@
<item name="android:textColorPrimary">@android:color/black</item>
<item name="android:textColor">@android:color/black</item>
<item name="preferenceTheme">@style/PreferenceThemeOverlay</item>
<item name="actionModeStyle">@style/ActionModeStyle</item>
</style>
<style name="KdeConnectThemeBase.NoActionBar" parent="KdeConnectThemeBase">
@ -42,4 +43,8 @@
<style name="DisableableButton" parent="ThemeOverlay.AppCompat">
<item name="colorButtonNormal">@drawable/disableable_button</item>
</style>
<style name="ActionModeStyle" parent="Widget.AppCompat.ActionMode">
<item name="background">@color/primaryDark</item>
</style>
</resources>

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<PreferenceCategory
android:key="@string/sftp_preference_key_preference_category"
android:title="@string/sftp_preference_detected_sdcards"
android:persistent="false">
</PreferenceCategory>
<org.kde.kdeconnect.Plugins.SftpPlugin.StoragePreference
android:key="key_sftp_add_storage"
android:icon="@drawable/ic_add"
android:title="@string/sftp_preference_add_storage_location_title"
android:persistent="false"/>
<androidx.preference.SwitchPreferenceCompat
android:defaultValue="true"
android:key="@string/sftp_preference_key_add_camera_shortcut"
android:summaryOff="@string/sftp_preference_add_camera_shortcut_summary_off"
android:summaryOn="@string/sftp_preference_add_camera_shortcut_summary_on"
android:title="@string/sftp_preference_add_camera_shortcut"/>
</PreferenceScreen>

View File

@ -20,7 +20,12 @@
package org.kde.kdeconnect.Helpers;
import android.annotation.TargetApi;
import android.content.Context;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.provider.DocumentsContract;
import java.io.BufferedReader;
import java.io.File;
@ -32,6 +37,8 @@ import java.util.List;
import java.util.Scanner;
import java.util.StringTokenizer;
import androidx.annotation.NonNull;
//Code from http://stackoverflow.com/questions/9340332/how-can-i-get-the-list-of-mounted-external-storage-of-android-device/19982338#19982338
//modified to work on Lollipop and other devices
public class StorageHelper {
@ -43,7 +50,7 @@ public class StorageHelper {
public final boolean removable;
public final int number;
StorageInfo(String path, boolean readonly, boolean removable, int number) {
public StorageInfo(String path, boolean readonly, boolean removable, int number) {
this.path = path;
this.readonly = readonly;
this.removable = removable;
@ -77,7 +84,7 @@ public class StorageHelper {
}
File storage = new File("/storage/");
if (storage.exists() && storage.isDirectory()) {
if (storage.exists() && storage.isDirectory() && storage.canRead()) {
String mounts = null;
try (Scanner scanner = new Scanner(new File("/proc/mounts"))) {
mounts = scanner.useDelimiter("\\A").next();
@ -100,7 +107,7 @@ public class StorageHelper {
if (!path.startsWith("/storage/emulated") || dirs.length == 1) {
if (!paths.contains(path) && !paths.contains(path2)) {
if (mounts == null || mounts.contains(path) || mounts.contains(path2)) {
list.add(0, new StorageInfo(path, false, true, cur_removable_number++));
list.add(0, new StorageInfo(path, dir.canWrite(), true, cur_removable_number++));
paths.add(path);
}
}
@ -153,4 +160,37 @@ public class StorageHelper {
return list;
}
/* treeUri documentId
* ==================================================================================================
* content://com.android.providers.downloads.documents/tree/downloads => downloads
* content://com.android.externalstorage.documents/tree/1715-1D1F: => 1715-1D1F:
* content://com.android.externalstorage.documents/tree/1715-1D1F:My%20Photos => 1715-1D1F:My Photos
* content://com.android.externalstorage.documents/tree/primary: => primary:
* content://com.android.externalstorage.documents/tree/primary:DCIM => primary:DCIM
* content://com.android.externalstorage.documents/tree/primary:Download/bla => primary:Download/bla
*/
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
public static String getDisplayName(@NonNull Context context, @NonNull Uri treeUri) {
List<String> pathSegments = treeUri.getPathSegments();
if (!pathSegments.get(0).equals("tree")) {
throw new IllegalArgumentException("treeUri is not valid");
}
String documentId = DocumentsContract.getTreeDocumentId(treeUri);
int colonIdx = pathSegments.get(1).indexOf(':');
if (colonIdx >= 0) {
String tree = pathSegments.get(1).substring(0, colonIdx + 1);
if (!documentId.equals(tree)) {
return documentId.substring(tree.length());
} else {
return documentId.substring(0, colonIdx);
}
}
return documentId;
}
}

View File

@ -0,0 +1,70 @@
/*
* Copyright 2018 Erik Duisters <e.duisters1@gmail.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License as
* published by the Free Software Foundation; either version 2 of
* the License or (at your option) version 3 or any later version
* accepted by the membership of KDE e.V. (or its successor approved
* by the membership of KDE e.V.), which shall act as a proxy
* defined in Section 14 of version 3 of the license.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.kde.kdeconnect.Plugins.SftpPlugin;
import android.content.Context;
import android.os.Build;
import org.apache.sshd.common.Session;
import org.apache.sshd.common.file.FileSystemFactory;
import org.apache.sshd.common.file.FileSystemView;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
class AndroidFileSystemFactory implements FileSystemFactory {
final private Context context;
final Map<String, String> roots;
AndroidFileSystemFactory(Context context) {
this.context = context;
this.roots = new HashMap<>();
}
void initRoots(List<SftpPlugin.StorageInfo> storageInfoList) {
for (SftpPlugin.StorageInfo curStorageInfo : storageInfoList) {
if (curStorageInfo.isFileUri()) {
if (curStorageInfo.uri.getPath() != null){
roots.put(curStorageInfo.displayName, curStorageInfo.uri.getPath());
}
} else if (curStorageInfo.isContentUri()){
roots.put(curStorageInfo.displayName, curStorageInfo.uri.toString());
}
}
}
@Override
public FileSystemView createFileSystemView(final Session username) {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) {
if (roots.size() == 0) {
throw new RuntimeException("roots cannot be empty");
}
String[] rootsAsString = new String[roots.size()];
roots.keySet().toArray(rootsAsString);
return new AndroidFileSystemView(roots, rootsAsString[0], username.getUsername(), context);
} else {
return new AndroidSafFileSystemView(roots, username.getUsername(), context);
}
}
}

View File

@ -0,0 +1,115 @@
/*
* Copyright 2018 Erik Duisters <e.duisters1@gmail.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License as
* published by the Free Software Foundation; either version 2 of
* the License or (at your option) version 3 or any later version
* accepted by the membership of KDE e.V. (or its successor approved
* by the membership of KDE e.V.), which shall act as a proxy
* defined in Section 14 of version 3 of the license.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.kde.kdeconnect.Plugins.SftpPlugin;
import android.content.Context;
import org.apache.sshd.common.file.FileSystemView;
import org.apache.sshd.common.file.SshFile;
import org.apache.sshd.common.file.nativefs.NativeFileSystemView;
import org.apache.sshd.common.file.nativefs.NativeSshFile;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
class AndroidFileSystemView extends NativeFileSystemView {
final private String userName;
final private Context context;
private final Map<String, String> roots;
private final RootFile rootFile;
AndroidFileSystemView(Map<String, String> roots, String currentRoot, final String userName, Context context) {
super(userName, roots, currentRoot, File.separatorChar, true);
this.roots = roots;
this.userName = userName;
this.context = context;
this.rootFile = new RootFile( createFileList(), userName, true);
}
private List<SshFile> createFileList() {
List<SshFile> list = new ArrayList<>();
for (Map.Entry<String, String> entry : roots.entrySet()) {
String displayName = entry.getKey();
String path = entry.getValue();
list.add(createNativeSshFile(displayName, new File(path), userName));
}
return list;
}
@Override
public SshFile getFile(String file) {
return getFile("/", file);
}
@Override
public SshFile getFile(SshFile baseDir, String file) {
return getFile(baseDir.getAbsolutePath(), file);
}
@Override
protected SshFile getFile(String dir, String file) {
if (!dir.endsWith("/")) {
dir = dir + "/";
}
if (!file.startsWith("/")) {
file = dir + file;
}
String filename = NativeSshFile.getPhysicalName("/", "/", file, false);
if (filename.equals("/")) {
return rootFile;
}
for (String root : roots.keySet()) {
if (filename.indexOf(root) == 1) {
String nameWithoutRoot = filename.substring(root.length() + 1);
String path = roots.get(root);
if (nameWithoutRoot.isEmpty()) {
return createNativeSshFile(filename, new File(path), userName);
} else {
return createNativeSshFile(filename, new File(path, nameWithoutRoot), userName);
}
}
}
//It's a file under / but not one covered by any Tree
return new RootFile(new ArrayList<>(0), userName, false);
}
// NativeFileSystemView.getFile(), NativeSshFile.getParentFile() and NativeSshFile.listSshFiles() call
// createNativeSshFile to create new NativeSshFiles so override that instead of getFile() to always create an AndroidSshFile
@Override
public AndroidSshFile createNativeSshFile(String name, File file, String username) {
return new AndroidSshFile(this, name, file, username, context);
}
@Override
public FileSystemView getNormalizedView() {
return this;
}
}

View File

@ -0,0 +1,128 @@
/*
* Copyright 2018 Erik Duisters <e.duisters1@gmail.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License as
* published by the Free Software Foundation; either version 2 of
* the License or (at your option) version 3 or any later version
* accepted by the membership of KDE e.V. (or its successor approved
* by the membership of KDE e.V.), which shall act as a proxy
* defined in Section 14 of version 3 of the license.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.kde.kdeconnect.Plugins.SftpPlugin;
import android.annotation.TargetApi;
import android.content.Context;
import android.net.Uri;
import android.provider.DocumentsContract;
import org.apache.sshd.common.file.FileSystemView;
import org.apache.sshd.common.file.SshFile;
import org.apache.sshd.common.file.nativefs.NativeSshFile;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@TargetApi(21)
public class AndroidSafFileSystemView implements FileSystemView {
final String userName;
final Context context;
private final Map<String, String> roots;
private final RootFile rootFile;
AndroidSafFileSystemView(Map<String, String> roots, String userName, Context context) {
this.roots = roots;
this.userName = userName;
this.context = context;
this.rootFile = new RootFile( createFileList(), userName, true);
}
private List<SshFile> createFileList() {
List<SshFile> list = new ArrayList<>();
for (Map.Entry<String, String> entry : roots.entrySet()) {
String displayName = entry.getKey();
String uri = entry.getValue();
Uri treeUri = Uri.parse(uri);
Uri documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, DocumentsContract.getTreeDocumentId(treeUri));
list.add(createAndroidSafSshFile(null, documentUri, File.separatorChar + displayName));
}
return list;
}
@Override
public SshFile getFile(String file) {
return getFile("/", file);
}
@Override
public SshFile getFile(SshFile baseDir, String file) {
return getFile(baseDir.getAbsolutePath(), file);
}
protected SshFile getFile(String dir, String file) {
if (!dir.endsWith("/")) {
dir = dir + "/";
}
if (!file.startsWith("/")) {
file = dir + file;
}
String filename = NativeSshFile.getPhysicalName("/", "/", file, false);
if (filename.equals("/")) {
return rootFile;
}
for (String root : roots.keySet()) {
if (filename.indexOf(root) == 1) {
String nameWithoutRoot = filename.substring(root.length() + 1);
String pathOrUri = roots.get(root);
Uri treeUri = Uri.parse(pathOrUri);
if (nameWithoutRoot.isEmpty()) {
//TreeDocument
Uri documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, DocumentsContract.getTreeDocumentId(treeUri));
return createAndroidSafSshFile(documentUri, documentUri, filename);
} else {
//ChildDocument, strip the leading / from nameWithoutRoot and append that to the treeDocumentId
String treeDocumentId = DocumentsContract.getTreeDocumentId(treeUri);
File nameWithoutRootFile = new File(nameWithoutRoot);
String parentSuffix = nameWithoutRootFile.getParent();
String parentDocumentId = treeDocumentId + (parentSuffix.equals("/") ? "" : parentSuffix.substring(1));
Uri parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, parentDocumentId);
Uri documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, treeDocumentId + nameWithoutRoot.substring(1));
return createAndroidSafSshFile(parentUri, documentUri, filename);
}
}
}
//It's a file under / but not one covered by any Tree
return new RootFile(new ArrayList<>(0), userName, false);
}
public AndroidSafSshFile createAndroidSafSshFile(Uri parentUri, Uri documentUri, String virtualFilename) {
return new AndroidSafSshFile(this, parentUri, documentUri, virtualFilename);
}
@Override
public FileSystemView getNormalizedView() {
return this;
}
}

View File

@ -0,0 +1,499 @@
/*
* Copyright 2018 Erik Duisters <e.duisters1@gmail.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License as
* published by the Free Software Foundation; either version 2 of
* the License or (at your option) version 3 or any later version
* accepted by the membership of KDE e.V. (or its successor approved
* by the membership of KDE e.V.), which shall act as a proxy
* defined in Section 14 of version 3 of the license.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.kde.kdeconnect.Plugins.SftpPlugin;
import android.annotation.TargetApi;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.provider.DocumentsContract;
import android.text.TextUtils;
import android.util.Log;
import org.apache.sshd.common.file.SshFile;
import org.kde.kdeconnect.Helpers.FilesHelper;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import androidx.annotation.Nullable;
@TargetApi(21)
public class AndroidSafSshFile implements SshFile {
private static final String TAG = AndroidSafSshFile.class.getSimpleName();
private final String virtualFileName;
private DocumentInfo documentInfo;
private Uri parentUri;
private final AndroidSafFileSystemView fileSystemView;
AndroidSafSshFile(final AndroidSafFileSystemView fileSystemView, Uri parentUri, Uri uri, String virtualFileName) {
this.fileSystemView = fileSystemView;
this.parentUri = parentUri;
this.documentInfo = new DocumentInfo(fileSystemView.context, uri);
this.virtualFileName = virtualFileName;
}
@Override
public String getAbsolutePath() {
return virtualFileName;
}
@Override
public String getName() {
/* From NativeSshFile, looks a lot like new File(virtualFileName).getName() to me */
// strip the last '/'
String shortName = virtualFileName;
int filelen = virtualFileName.length();
if (shortName.charAt(filelen - 1) == File.separatorChar) {
shortName = shortName.substring(0, filelen - 1);
}
// return from the last '/'
int slashIndex = shortName.lastIndexOf(File.separatorChar);
if (slashIndex != -1) {
shortName = shortName.substring(slashIndex + 1);
}
return shortName;
}
@Override
public String getOwner() {
return fileSystemView.userName;
}
@Override
public boolean isDirectory() {
return documentInfo.isDirectory;
}
@Override
public boolean isFile() {
return documentInfo.isFile;
}
@Override
public boolean doesExist() {
return documentInfo.exists;
}
@Override
public long getSize() {
return documentInfo.length;
}
@Override
public long getLastModified() {
return documentInfo.lastModified;
}
@Override
public boolean setLastModified(long time) {
//TODO
/* Throws UnsupportedOperationException on API 26
try {
ContentValues updateValues = new ContentValues();
updateValues.put(DocumentsContract.Document.COLUMN_LAST_MODIFIED, time);
result = fileSystemView.context.getContentResolver().update(documentInfo.uri, updateValues, null, null) != 0;
documentInfo.lastModified = time;
} catch (NullPointerException ignored) {}
*/
return true;
}
@Override
public boolean isReadable() {
return documentInfo.canRead;
}
@Override
public boolean isWritable() {
return documentInfo.canWrite;
}
@Override
public boolean isExecutable() {
return documentInfo.isDirectory;
}
@Override
public boolean isRemovable() {
Log.d(TAG, "isRemovable() - is this ever called?");
return false;
}
public SshFile getParentFile() {
Log.d(TAG,"getParentFile() - is this ever called");
return null;
}
@Override
public boolean delete() {
boolean ret;
try {
ret = DocumentsContract.deleteDocument(fileSystemView.context.getContentResolver(), documentInfo.uri);
} catch (FileNotFoundException e) {
ret = false;
}
return ret;
}
@Override
public boolean create() {
return create(parentUri, FilesHelper.getMimeTypeFromFile(virtualFileName), getName());
}
private boolean create(Uri parentUri, String mimeType, String name) {
Uri uri = null;
try {
uri = DocumentsContract.createDocument(fileSystemView.context.getContentResolver(), parentUri, mimeType, name);
if (uri != null) {
documentInfo = new DocumentInfo(fileSystemView.context, uri);
}
} catch (FileNotFoundException ignored) {}
return uri != null;
}
@Override
public void truncate() throws IOException {
if (documentInfo.length > 0) {
delete();
create();
}
}
@Override
public boolean move(final SshFile dest) {
boolean success = false;
Uri destParentUri = ((AndroidSafSshFile)dest).parentUri;
if (destParentUri.equals(parentUri)) {
//Rename
try {
Uri newUri = DocumentsContract.renameDocument(fileSystemView.context.getContentResolver(), documentInfo.uri, dest.getName());
if (newUri != null) {
success = true;
documentInfo.uri = newUri;
}
} catch (FileNotFoundException ignored) {}
} else {
// Move:
String sourceTreeDocumentId = DocumentsContract.getTreeDocumentId(parentUri);
String destTreeDocumentId = DocumentsContract.getTreeDocumentId(((AndroidSafSshFile) dest).parentUri);
if (sourceTreeDocumentId.equals(destTreeDocumentId) && Build.VERSION.SDK_INT >= 24) {
try {
Uri newUri = DocumentsContract.moveDocument(fileSystemView.context.getContentResolver(), documentInfo.uri, parentUri, destParentUri);
if (newUri != null) {
success = true;
parentUri = destParentUri;
documentInfo.uri = newUri;
}
} catch (Exception ignored) {
Log.e(TAG,"DocumentsContract.moveDocument() threw an exception: " + ignored.getMessage());
}
} else {
try {
if (dest.create()) {
try (InputStream in = createInputStream(0); OutputStream out = dest.createOutputStream(0)) {
byte[] buffer = new byte[10 * 1024];
int read;
while ((read = in.read(buffer)) > 0) {
out.write(buffer, 0, read);
}
out.flush();
delete();
success = true;
} catch (IOException e) {
if (dest.doesExist()) {
dest.delete();
}
}
}
} catch (IOException ignored) {}
}
}
return success;
}
@Override
public boolean mkdir() {
return create(parentUri, DocumentsContract.Document.MIME_TYPE_DIR, getName());
}
@Override
public List<SshFile> listSshFiles() {
if (!documentInfo.isDirectory) {
return null;
}
final ContentResolver resolver = fileSystemView.context.getContentResolver();
final Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(documentInfo.uri, DocumentsContract.getDocumentId(documentInfo.uri));
final ArrayList<AndroidSafSshFile> results = new ArrayList<>();
Cursor c = resolver.query(childrenUri, new String[]
{ DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME }, null, null, null);
while (c != null && c.moveToNext()) {
final String documentId = c.getString(c.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID));
final String displayName = c.getString(c.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME));
final Uri documentUri = DocumentsContract.buildDocumentUriUsingTree(documentInfo.uri, documentId);
results.add(new AndroidSafSshFile(fileSystemView, parentUri, documentUri, virtualFileName + File.separatorChar + displayName));
}
if (c != null) {
c.close();
}
return Collections.unmodifiableList(results);
}
@Override
public OutputStream createOutputStream(final long offset) throws IOException {
return fileSystemView.context.getContentResolver().openOutputStream(documentInfo.uri);
}
@Override
public InputStream createInputStream(final long offset) throws IOException {
return fileSystemView.context.getContentResolver().openInputStream(documentInfo.uri);
}
@Override
public void handleClose() {
// Nop
}
@Override
public Map<Attribute, Object> getAttributes(boolean followLinks) throws IOException {
Map<SshFile.Attribute, Object> attributes = new HashMap<>();
for (SshFile.Attribute attr : SshFile.Attribute.values()) {
switch (attr) {
case Uid:
case Gid:
case NLink:
continue;
}
attributes.put(attr, getAttribute(attr, followLinks));
}
return attributes;
}
@Override
public Object getAttribute(Attribute attribute, boolean followLinks) throws IOException {
Object ret;
switch (attribute) {
case Size:
ret = documentInfo.length;
break;
case Uid:
ret = 1;
break;
case Owner:
ret = getOwner();
break;
case Gid:
ret = 1;
break;
case Group:
ret = getOwner();
break;
case IsDirectory:
ret = documentInfo.isDirectory;
break;
case IsRegularFile:
ret = documentInfo.isFile;
break;
case IsSymbolicLink:
ret = false;
break;
case Permissions:
Set<Permission> tmp = new HashSet<>();
if (documentInfo.canRead) {
tmp.add(SshFile.Permission.UserRead);
tmp.add(SshFile.Permission.GroupRead);
tmp.add(SshFile.Permission.OthersRead);
}
if (documentInfo.canWrite) {
tmp.add(SshFile.Permission.UserWrite);
tmp.add(SshFile.Permission.GroupWrite);
tmp.add(SshFile.Permission.OthersWrite);
}
if (isExecutable()) {
tmp.add(SshFile.Permission.UserExecute);
tmp.add(SshFile.Permission.GroupExecute);
tmp.add(SshFile.Permission.OthersExecute);
}
ret = tmp.isEmpty()
? EnumSet.noneOf(SshFile.Permission.class)
: EnumSet.copyOf(tmp);
break;
case CreationTime:
ret = documentInfo.lastModified;
break;
case LastModifiedTime:
ret = documentInfo.lastModified;
break;
case LastAccessTime:
ret = documentInfo.lastModified;
break;
case NLink:
ret = 0;
break;
default:
ret = null;
break;
}
return ret;
}
@Override
public void setAttributes(Map<Attribute, Object> attributes) {
//TODO: Using Java 7 NIO it should be possible to implement setting a number of attributes but does SaF allow that?
Log.d(TAG, "setAttributes()");
}
@Override
public void setAttribute(Attribute attribute, Object value) throws IOException {
Log.d(TAG, "setAttribute()");
}
@Override
public String readSymbolicLink() throws IOException {
throw new IOException("Not Implemented");
}
@Override
public void createSymbolicLink(SshFile destination) throws IOException {
throw new IOException("Not Implemented");
}
/**
* Retrieve all file info using 1 query to speed things up
* The only fields guaranteed to be initialized are uri and exists
*/
private static class DocumentInfo {
private Uri uri;
private boolean exists;
@Nullable
private String documentId;
private boolean canRead;
private boolean canWrite;
@Nullable
private String mimeType;
private boolean isDirectory;
private boolean isFile;
private long lastModified;
private long length;
@Nullable
private String displayName;
private static final String[] columns;
static {
columns = new String[]{
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_LAST_MODIFIED,
//DocumentsContract.Document.COLUMN_ICON,
DocumentsContract.Document.COLUMN_FLAGS,
DocumentsContract.Document.COLUMN_SIZE
};
}
/*
Based on https://github.com/rcketscientist/DocumentActivity
Extracted from android.support.v4.provider.DocumentsContractAPI19 and android.support.v4.provider.DocumentsContractAPI21
*/
private DocumentInfo(Context c, Uri uri)
{
this.uri = uri;
try (Cursor cursor = c.getContentResolver().query(uri, columns, null, null, null)) {
exists = cursor != null && cursor.getCount() > 0;
if (!exists)
return;
cursor.moveToFirst();
documentId = cursor.getString(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID));
final boolean readPerm = c.checkCallingOrSelfUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
== PackageManager.PERMISSION_GRANTED;
final boolean writePerm = c.checkCallingOrSelfUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
== PackageManager.PERMISSION_GRANTED;
final int flags = cursor.getInt(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_FLAGS));
final boolean supportsDelete = (flags & DocumentsContract.Document.FLAG_SUPPORTS_DELETE) != 0;
final boolean supportsCreate = (flags & DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE) != 0;
final boolean supportsWrite = (flags & DocumentsContract.Document.FLAG_SUPPORTS_WRITE) != 0;
mimeType = cursor.getString(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE));
final boolean hasMime = !TextUtils.isEmpty(mimeType);
isDirectory = DocumentsContract.Document.MIME_TYPE_DIR.equals(mimeType);
isFile = !isDirectory && hasMime;
canRead = readPerm && hasMime;
canWrite = writePerm && (supportsDelete || (isDirectory && supportsCreate) || (hasMime && supportsWrite));
displayName = cursor.getString(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME));
lastModified = cursor.getLong(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_LAST_MODIFIED));
length = cursor.getLong(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_SIZE));
} catch (IllegalArgumentException e) {
//File does not exist, it's probably going to be created
exists = false;
canWrite = true;
}
}
}
}

View File

@ -0,0 +1,114 @@
/*
* Copyright 2018 Erik Duisters <e.duisters1@gmail.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License as
* published by the Free Software Foundation; either version 2 of
* the License or (at your option) version 3 or any later version
* accepted by the membership of KDE e.V. (or its successor approved
* by the membership of KDE e.V.), which shall act as a proxy
* defined in Section 14 of version 3 of the license.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.kde.kdeconnect.Plugins.SftpPlugin;
import android.content.Context;
import android.net.Uri;
import org.apache.sshd.common.file.nativefs.NativeSshFile;
import org.kde.kdeconnect.Helpers.MediaStoreHelper;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.RandomAccessFile;
class AndroidSshFile extends NativeSshFile {
private final static String TAG = AndroidSshFile.class.getSimpleName();
final private Context context;
final private File file;
AndroidSshFile(final AndroidFileSystemView view, String name, final File file, final String userName, Context context) {
super(view, name, file, userName);
this.context = context;
this.file = file;
}
@Override
public OutputStream createOutputStream(long offset) throws IOException {
if (!isWritable()) {
throw new IOException("No write permission : " + file.getName());
}
final RandomAccessFile raf = new RandomAccessFile(file, "rw");
try {
if (offset < raf.length()) {
throw new IOException("Your SSHFS is bugged"); //SSHFS 3.0 and 3.2 cause data corruption, abort the transfer if this happens
}
raf.setLength(offset);
raf.seek(offset);
return new FileOutputStream(raf.getFD()) {
public void close() throws IOException {
super.close();
raf.close();
}
};
} catch (IOException e) {
raf.close();
throw e;
}
}
@Override
public boolean delete() {
boolean ret = super.delete();
if (ret) {
MediaStoreHelper.indexFile(context, Uri.fromFile(file));
}
return ret;
}
@Override
public boolean create() throws IOException {
boolean ret = super.create();
if (ret) {
MediaStoreHelper.indexFile(context, Uri.fromFile(file));
}
return ret;
}
// Based on https://github.com/wolpi/prim-ftpd/blob/master/primitiveFTPd/src/org/primftpd/filesystem/FsFile.java
@Override
public boolean doesExist() {
boolean exists = file.exists();
if (!exists) {
// file.exists() returns false when we don't have read permission
// try to figure out if it really does not exist
File parentFile = file.getParentFile();
File[] children = parentFile.listFiles();
if (children != null) {
for (File child : children) {
if (file.equals(child)) {
exists = true;
break;
}
}
}
}
return exists;
}
}

View File

@ -0,0 +1,178 @@
/*
* Copyright 2018 Erik Duisters <e.duisters1@gmail.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License as
* published by the Free Software Foundation; either version 2 of
* the License or (at your option) version 3 or any later version
* accepted by the membership of KDE e.V. (or its successor approved
* by the membership of KDE e.V.), which shall act as a proxy
* defined in Section 14 of version 3 of the license.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.kde.kdeconnect.Plugins.SftpPlugin;
import org.apache.sshd.common.file.SshFile;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Calendar;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
//TODO: ls .. and ls / only show .. and / respectively I would expect a listing
//TODO: cd .. to / does not work and prints "Can't change directory: Can't check target"
class RootFile implements SshFile {
private final boolean exists;
private final String userName;
private final List<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) throws IOException {
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 null;
}
public void createSymbolicLink(SshFile destination) {
}
public String getOwner() {
return null;
}
public boolean isDirectory() {
return true;
}
public boolean isFile() {
return false;
}
public boolean doesExist() {
return exists;
}
public boolean isReadable() {
return true;
}
public boolean isWritable() {
return false;
}
public boolean isExecutable() {
return true;
}
public boolean isRemovable() {
return false;
}
public SshFile getParentFile() {
return this;
}
public long getLastModified() {
return 0;
}
public boolean setLastModified(long time) {
return false;
}
public long getSize() {
return 0;
}
public boolean mkdir() {
return false;
}
public boolean delete() {
return false;
}
public boolean create() {
return false;
}
public void truncate() {
}
public boolean move(SshFile destination) {
return false;
}
public List<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

@ -20,28 +20,42 @@
package org.kde.kdeconnect.Plugins.SftpPlugin;
import android.Manifest;
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 org.kde.kdeconnect.Helpers.StorageHelper;
import org.json.JSONException;
import org.json.JSONObject;
import org.kde.kdeconnect.NetworkPacket;
import org.kde.kdeconnect.Plugins.Plugin;
import org.kde.kdeconnect.Plugins.PluginFactory;
import org.kde.kdeconnect.UserInterface.AlertDialogFragment;
import org.kde.kdeconnect.UserInterface.DeviceSettingsAlertDialogFragment;
import org.kde.kdeconnect.UserInterface.PluginSettingsFragment;
import org.kde.kdeconnect_tp.R;
import java.io.File;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.preference.PreferenceManager;
@PluginFactory.LoadablePlugin
public class SftpPlugin extends Plugin {
public class SftpPlugin extends Plugin implements SharedPreferences.OnSharedPreferenceChangeListener {
private final static String PACKET_TYPE_SFTP = "kdeconnect.sftp";
private final static String PACKET_TYPE_SFTP_REQUEST = "kdeconnect.sftp.request";
private static final SimpleSftpServer server = new SimpleSftpServer();
private String KeyStorageInfoList;
private String KeyAddCameraShortcut;
@Override
public String getDisplayName() {
return context.getResources().getString(R.string.pref_plugin_sftp);
@ -54,9 +68,16 @@ public class SftpPlugin extends Plugin {
@Override
public boolean onCreate() {
permissionExplanation = R.string.sftp_permission_explanation;
KeyStorageInfoList = context.getString(R.string.sftp_preference_key_storage_info_list);
KeyAddCameraShortcut = context.getString(R.string.sftp_preference_key_add_camera_shortcut);
try {
server.init(context, device);
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) {
return SftpSettingsFragment.getStorageInfoList(context).size() != 0;
}
return true;
} catch (Exception e) {
e.printStackTrace();
@ -64,69 +85,77 @@ public class SftpPlugin extends Plugin {
}
}
@Override
public boolean checkOptionalPermissions() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
return SftpSettingsFragment.getStorageInfoList(context).size() != 0;
}
return true;
}
@Override
public AlertDialogFragment getOptionalPermissionExplanationDialog(int requestCode) {
return new DeviceSettingsAlertDialogFragment.Builder()
.setTitle(getDisplayName())
.setMessage(R.string.sftp_saf_permission_explanation)
.setPositiveButton(R.string.ok)
.setNegativeButton(R.string.cancel)
.setDeviceId(device.getDeviceId())
.setPluginKey(getPluginKey())
.create();
}
@Override
public void onDestroy() {
server.stop();
PreferenceManager.getDefaultSharedPreferences(context).unregisterOnSharedPreferenceChangeListener(this);
}
@Override
public boolean onPacketReceived(NetworkPacket np) {
if (np.getBoolean("startBrowsing")) {
if (server.start()) {
ArrayList<String> paths = new ArrayList<>();
ArrayList<String> pathNames = new ArrayList<>();
List<StorageInfo> storageInfoList = SftpSettingsFragment.getStorageInfoList(context);
Collections.sort(storageInfoList, new StorageInfo.UriNameComparator());
if (storageInfoList.size() > 0) {
getPathsAndNamesForStorageInfoList(paths, pathNames, storageInfoList);
} else {
NetworkPacket np2 = new NetworkPacket(PACKET_TYPE_SFTP);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
np2.set("errorMessage", context.getString(R.string.sftp_no_storage_locations_configured));
} else {
np2.set("errorMessage", context.getString(R.string.sftp_no_sdcard_detected));
}
device.sendPacket(np2);
return true;
}
removeChildren(storageInfoList);
if (server.start(storageInfoList)) {
PreferenceManager.getDefaultSharedPreferences(context).registerOnSharedPreferenceChangeListener(this);
NetworkPacket np2 = new NetworkPacket(PACKET_TYPE_SFTP);
//TODO: ip is not used on desktop any more remove both here and from desktop code when nobody ships 1.2.0
np2.set("ip", server.getLocalIpAddress());
np2.set("port", server.getPort());
np2.set("user", SimpleSftpServer.USER);
np2.set("password", server.getPassword());
//Kept for compatibility, in case "multiPaths" is not possible or the other end does not support it
np2.set("path", Environment.getExternalStorageDirectory().getAbsolutePath());
List<StorageHelper.StorageInfo> storageList = StorageHelper.getStorageList();
ArrayList<String> paths = new ArrayList<>();
ArrayList<String> pathNames = new ArrayList<>();
for (StorageHelper.StorageInfo storage : storageList) {
paths.add(storage.path);
StringBuilder res = new StringBuilder();
if (storageList.size() > 1) {
if (!storage.removable) {
res.append(context.getString(R.string.sftp_internal_storage));
} else if (storage.number > 1) {
res.append(context.getString(R.string.sftp_sdcard_num, storage.number));
} else {
res.append(context.getString(R.string.sftp_sdcard));
}
} else {
res.append(context.getString(R.string.sftp_all_files));
}
String pathName = res.toString();
if (storage.readonly) {
res.append(" ");
res.append(context.getString(R.string.sftp_readonly));
}
pathNames.add(res.toString());
//Shortcut for users that only want to browse camera pictures
String dcim = storage.path + "/DCIM/Camera";
if (new File(dcim).exists()) {
paths.add(dcim);
if (storageList.size() > 1) {
pathNames.add(context.getString(R.string.sftp_camera) + "(" + pathName + ")");
} else {
pathNames.add(context.getString(R.string.sftp_camera));
}
}
}
np2.set("path", "/");
if (paths.size() > 0) {
np2.set("multiPaths", paths);
np2.set("pathNames", pathNames);
}
device.sendPacket(np2);
@ -137,12 +166,66 @@ public class SftpPlugin extends Plugin {
return false;
}
@Override
public String[] getRequiredPermissions() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
return new String[]{Manifest.permission.READ_EXTERNAL_STORAGE};
private void getPathsAndNamesForStorageInfoList(List<String> paths, List<String> pathNames, List<StorageInfo> storageInfoList) {
StorageInfo prevInfo = null;
StringBuilder pathBuilder = new StringBuilder();
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
boolean addCameraShortcuts = false;
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) {
addCameraShortcuts = prefs.getBoolean(context.getString(R.string.sftp_preference_key_add_camera_shortcut), true);
}
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 {
return new String[0];
throw new RuntimeException("curInfo.uri.getPath() or parentInfo.uri.getPath() returned null");
}
} else {
pathBuilder.append(curInfo.displayName);
if (prevInfo == null || !curInfo.uri.toString().startsWith(prevInfo.uri.toString())) {
prevInfo = curInfo;
}
}
paths.add(pathBuilder.toString());
pathNames.add(curInfo.displayName);
if (addCameraShortcuts) {
if (new File(curInfo.uri.getPath(), "/DCIM/Camera").exists()) {
paths.add(pathBuilder.toString() + "/DCIM/Camera");
if (storageInfoList.size() > 1) {
pathNames.add(context.getString(R.string.sftp_camera) + "(" + curInfo.displayName + ")");
} else {
pathNames.add(context.getString(R.string.sftp_camera));
}
}
}
}
}
private void removeChildren(List<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;
}
}
}
}
@ -156,4 +239,102 @@ public class SftpPlugin extends Plugin {
return new String[]{PACKET_TYPE_SFTP};
}
@Override
public boolean hasSettings() {
return true;
}
@Override
public PluginSettingsFragment getSettingsFragment(Activity activity) {
return SftpSettingsFragment.newInstance(getPluginKey());
}
@Override
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
if (key.equals(KeyStorageInfoList) || key.equals(KeyAddCameraShortcut)) {
//TODO: There used to be a way to request an un-mount (see desktop SftpPlugin's Mounter::onPackageReceived) but that is not handled anymore by the SftpPlugin on KDE.
if (server.isStarted()) {
server.stop();
NetworkPacket np = new NetworkPacket(PACKET_TYPE_SFTP_REQUEST);
np.set("startBrowsing", true);
onPacketReceived(np);
}
}
}
static class StorageInfo {
private static final String KEY_DISPLAY_NAME = "DisplayName";
private static final String KEY_URI = "Uri";
@NonNull String displayName;
@NonNull Uri uri;
StorageInfo(@NonNull String displayName, @NonNull Uri uri) {
this.displayName = displayName;
this.uri = uri;
}
static StorageInfo copy(StorageInfo from) {
//Both String and Uri are immutable
return new StorageInfo(from.displayName, from.uri);
}
boolean isFileUri() {
return uri.getScheme().equals(ContentResolver.SCHEME_FILE);
}
boolean isContentUri() {
return uri.getScheme().equals(ContentResolver.SCHEME_CONTENT);
}
public JSONObject toJSON() throws JSONException {
JSONObject jsonObject = new JSONObject();
jsonObject.put(KEY_DISPLAY_NAME, displayName);
jsonObject.put(KEY_URI, uri.toString());
return jsonObject;
}
@NonNull
static StorageInfo fromJSON(@NonNull JSONObject jsonObject) throws JSONException {
String displayName = jsonObject.getString(KEY_DISPLAY_NAME);
Uri uri = Uri.parse(jsonObject.getString(KEY_URI));
return new StorageInfo(displayName, uri);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
StorageInfo that = (StorageInfo) o;
if (!displayName.equals(that.displayName)) return false;
return uri.equals(that.uri);
}
@Override
public int hashCode() {
int result = displayName.hashCode();
result = 31 * result + uri.hashCode();
return result;
}
static class DisplayNameComparator implements java.util.Comparator<StorageInfo> {
@Override
public int compare(StorageInfo si1, StorageInfo si2) {
return si1.displayName.compareToIgnoreCase(si2.displayName);
}
}
static class UriNameComparator implements java.util.Comparator<StorageInfo> {
@Override
public int compare(StorageInfo si1, StorageInfo si2) {
return si1.uri.compareTo(si2.uri);
}
}
}
}

View File

@ -0,0 +1,499 @@
/*
* Copyright 2018 Erik Duisters <e.duisters1@gmail.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License as
* published by the Free Software Foundation; either version 2 of
* the License or (at your option) version 3 or any later version
* accepted by the membership of KDE e.V. (or its successor approved
* by the membership of KDE e.V.), which shall act as a proxy
* defined in Section 14 of version 3 of the license.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.kde.kdeconnect.Plugins.SftpPlugin;
import android.annotation.TargetApi;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.res.TypedArray;
import android.graphics.PorterDuff;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.util.SparseBooleanArray;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.kde.kdeconnect.BackgroundService;
import org.kde.kdeconnect.Device;
import org.kde.kdeconnect.Helpers.StorageHelper;
import org.kde.kdeconnect.UserInterface.DeviceSettingsActivity;
import org.kde.kdeconnect.UserInterface.PluginSettingsFragment;
import org.kde.kdeconnect_tp.R;
import java.io.File;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.ListIterator;
import androidx.annotation.NonNull;
import androidx.appcompat.view.ActionMode;
import androidx.fragment.app.Fragment;
import androidx.preference.Preference;
import androidx.preference.PreferenceCategory;
import androidx.preference.PreferenceManager;
import androidx.preference.PreferenceScreen;
import androidx.recyclerview.widget.RecyclerView;
//TODO: Is it possible on API 19 to select a directory and then have write permission for everything beneath it
//TODO: Is it necessary to check if uri permissions are still in place? If it is make the user aware of the fact (red text or something)
public class SftpSettingsFragment
extends PluginSettingsFragment
implements StoragePreferenceDialogFragment.Callback,
Preference.OnPreferenceChangeListener,
StoragePreference.OnLongClickListener, ActionMode.Callback {
private final static String KEY_STORAGE_PREFERENCE_DIALOG = "StoragePreferenceDialog";
private final static String KEY_ACTION_MODE_STATE = "ActionModeState";
private final static String KEY_ACTION_MODE_ENABLED = "ActionModeEnabled";
private final static String KEY_ACTION_MODE_SELECTED_ITEMS = "ActionModeSelectedItems";
private List<SftpPlugin.StorageInfo> storageInfoList;
private PreferenceCategory preferenceCategory;
private ActionMode actionMode;
private JSONObject savedActionModeState;
public static SftpSettingsFragment newInstance(@NonNull String pluginKey) {
SftpSettingsFragment fragment = new SftpSettingsFragment();
fragment.setArguments(pluginKey);
return fragment;
}
public SftpSettingsFragment() {}
@Override
public void onCreate(Bundle savedInstanceState) {
// super.onCreate creates PreferenceManager and calls onCreatePreferences()
super.onCreate(savedInstanceState);
if (getFragmentManager() != null) {
Fragment fragment = getFragmentManager().findFragmentByTag(KEY_STORAGE_PREFERENCE_DIALOG);
if (fragment != null) {
((StoragePreferenceDialogFragment) fragment).setCallback(this);
}
}
if (savedInstanceState != null && savedInstanceState.containsKey(KEY_ACTION_MODE_STATE)) {
try {
savedActionModeState = new JSONObject(savedInstanceState.getString(KEY_ACTION_MODE_STATE, "{}"));
} catch (JSONException ignored) {}
}
}
@Override
public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
super.onCreatePreferences(savedInstanceState, rootKey);
TypedArray ta = requireContext().obtainStyledAttributes(new int[]{R.attr.colorAccent});
int colorAccent = ta.getColor(0, 0);
ta.recycle();
int sdkInt = Build.VERSION.SDK_INT;
storageInfoList = getStorageInfoList(requireContext());
PreferenceScreen preferenceScreen = getPreferenceScreen();
preferenceCategory = (PreferenceCategory) preferenceScreen
.findPreference(getString(R.string.sftp_preference_key_preference_category));
if (sdkInt <= 19) {
preferenceCategory.setTitle(R.string.sftp_preference_detected_sdcards);
} else {
preferenceCategory.setTitle(R.string.sftp_preference_configured_storage_locations);
}
addStoragePreferences(preferenceCategory);
Preference addStoragePreference = preferenceScreen.findPreference(getString(R.string.sftp_preference_key_add_storage));
addStoragePreference.getIcon().setColorFilter(colorAccent, PorterDuff.Mode.SRC_IN);
if (sdkInt <= 19) {
addStoragePreference.setVisible(false);
}
Preference addCameraShortcutPreference = preferenceScreen.findPreference(getString(R.string.sftp_preference_key_add_camera_shortcut));
if (sdkInt > 19) {
addCameraShortcutPreference.setVisible(false);
}
}
private void addStoragePreferences(PreferenceCategory preferenceCategory) {
/*
https://developer.android.com/guide/topics/ui/settings/programmatic-hierarchy
You can't just use any context to create Preferences, you have to use PreferenceManager's context
*/
Context context = getPreferenceManager().getContext();
sortStorageInfoListOnDisplayName();
for (int i = 0; i < storageInfoList.size(); i++) {
SftpPlugin.StorageInfo storageInfo = storageInfoList.get(i);
StoragePreference preference = new StoragePreference(context);
preference.setOnPreferenceChangeListener(this);
if (Build.VERSION.SDK_INT >= 21) {
preference.setOnLongClickListener(this);
}
preference.setKey(getString(R.string.sftp_preference_key_storage_info, i));
preference.setIcon(android.R.color.transparent);
preference.setDefaultValue(storageInfo);
if (storageInfo.isFileUri()) {
preference.setDialogTitle(R.string.sftp_preference_edit_sdcard_title);
} else {
preference.setDialogTitle(R.string.sftp_preference_edit_storage_location);
}
preferenceCategory.addPreference(preference);
}
}
@Override
protected RecyclerView.Adapter onCreateAdapter(PreferenceScreen preferenceScreen) {
if (savedActionModeState != null) {
getListView().post(this::restoreActionMode);
}
return super.onCreateAdapter(preferenceScreen);
}
private void restoreActionMode() {
try {
if (savedActionModeState.getBoolean(KEY_ACTION_MODE_ENABLED)) {
actionMode = ((DeviceSettingsActivity)requireActivity()).startSupportActionMode(this);
if (actionMode != null) {
JSONArray jsonArray = savedActionModeState.getJSONArray(KEY_ACTION_MODE_SELECTED_ITEMS);
SparseBooleanArray selectedItems = new SparseBooleanArray();
for (int i = 0, count = jsonArray.length(); i < count; i++) {
selectedItems.put(jsonArray.getInt(i), true);
}
for (int i = 0, count = preferenceCategory.getPreferenceCount(); i < count; i++) {
StoragePreference preference = (StoragePreference) preferenceCategory.getPreference(i);
preference.setSelectable(true);
preference.checkbox.setChecked(selectedItems.get(i, false));
}
}
}
} catch (JSONException ignored) {}
}
@Override
public void onDisplayPreferenceDialog(Preference preference) {
if (preference instanceof StoragePreference) {
StoragePreferenceDialogFragment fragment = StoragePreferenceDialogFragment.newInstance(preference.getKey());
fragment.setTargetFragment(this, 0);
fragment.setCallback(this);
fragment.show(requireFragmentManager(), KEY_STORAGE_PREFERENCE_DIALOG);
} else {
super.onDisplayPreferenceDialog(preference);
}
}
@Override
public void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
try {
JSONObject jsonObject = new JSONObject();
jsonObject.put(KEY_ACTION_MODE_ENABLED, actionMode != null);
if (actionMode != null) {
JSONArray jsonArray = new JSONArray();
for (int i = 0, count = preferenceCategory.getPreferenceCount(); i < count; i++) {
StoragePreference preference = (StoragePreference) preferenceCategory.getPreference(i);
if (preference.checkbox.isChecked()) {
jsonArray.put(i);
}
}
jsonObject.put(KEY_ACTION_MODE_SELECTED_ITEMS, jsonArray);
}
outState.putString(KEY_ACTION_MODE_STATE, jsonObject.toString());
} catch (JSONException ignored) {}
}
private void saveStorageInfoList() {
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(requireContext());
JSONArray jsonArray = new JSONArray();
try {
for (SftpPlugin.StorageInfo storageInfo : storageInfoList) {
jsonArray.put(storageInfo.toJSON());
}
} catch (JSONException ignored) {}
preferences
.edit()
.putString(requireContext().getString(R.string.sftp_preference_key_storage_info_list), jsonArray.toString())
.apply();
}
@NonNull
static List<SftpPlugin.StorageInfo> getStorageInfoList(@NonNull Context context) {
ArrayList<SftpPlugin.StorageInfo> storageInfoList = new ArrayList<>();
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context);
String jsonString = preferences
.getString(context.getString(R.string.sftp_preference_key_storage_info_list), "[]");
try {
JSONArray jsonArray = new JSONArray(jsonString);
for (int i = 0; i < jsonArray.length(); i++) {
storageInfoList.add(SftpPlugin.StorageInfo.fromJSON(jsonArray.getJSONObject(i)));
}
} catch (JSONException e) {
e.printStackTrace();
}
if (Build.VERSION.SDK_INT <= 19) {
addDetectedSDCardsToStorageInfoList(context, storageInfoList);
}
return storageInfoList;
}
private static void addDetectedSDCardsToStorageInfoList(@NonNull Context context, List<SftpPlugin.StorageInfo> storageInfoList) {
List<StorageHelper.StorageInfo> storageHelperInfoList = StorageHelper.getStorageList();
for (StorageHelper.StorageInfo info : storageHelperInfoList) {
// on at least API 17 emulator Environment.isExternalStorageRemovable returns false
if (info.removable || info.path.startsWith(Environment.getExternalStorageDirectory().getPath())) {
StringBuilder displayNameBuilder = new StringBuilder();
StringBuilder displayNameReadOnlyBuilder = new StringBuilder();
Uri sdCardUri = Uri.fromFile(new File(info.path));
if (isAlreadyConfigured(storageInfoList, sdCardUri)) {
continue;
}
int i = 1;
do {
if (i == 1) {
displayNameBuilder.append(context.getString(R.string.sftp_sdcard));
} else {
displayNameBuilder.setLength(0);
displayNameBuilder.append(context.getString(R.string.sftp_sdcard_num, i));
}
displayNameReadOnlyBuilder
.append(displayNameBuilder)
.append(" ")
.append(context.getString(R.string.sftp_readonly));
i++;
} while (!isDisplayNameUnique(storageInfoList, displayNameBuilder.toString(), displayNameReadOnlyBuilder.toString()));
String displayName = info.readonly ?
displayNameReadOnlyBuilder.toString() : displayNameBuilder.toString();
storageInfoList.add(new SftpPlugin.StorageInfo(displayName, Uri.fromFile(new File(info.path))));
}
}
}
private static boolean isDisplayNameUnique(List<SftpPlugin.StorageInfo> storageInfoList, String displayName, String displayNameReadOnly) {
for (SftpPlugin.StorageInfo info : storageInfoList) {
if (info.displayName.equals(displayName) || info.displayName.equals(displayName + displayNameReadOnly)) {
return false;
}
}
return true;
}
private static boolean isAlreadyConfigured(List<SftpPlugin.StorageInfo> storageInfoList, Uri sdCardUri) {
for (SftpPlugin.StorageInfo info : storageInfoList) {
if (info.uri.equals(sdCardUri)) {
return true;
}
}
return false;
}
private void sortStorageInfoListOnDisplayName() {
Collections.sort(storageInfoList, new SftpPlugin.StorageInfo.DisplayNameComparator());
}
@NonNull
@Override
public StoragePreferenceDialogFragment.CallbackResult isDisplayNameAllowed(@NonNull String displayName) {
StoragePreferenceDialogFragment.CallbackResult result = new StoragePreferenceDialogFragment.CallbackResult();
result.isAllowed = true;
if (displayName.isEmpty()) {
result.isAllowed = false;
result.errorMessage = getString(R.string.sftp_storage_preference_display_name_cannot_be_empty);
} else {
for (SftpPlugin.StorageInfo storageInfo : storageInfoList) {
if (storageInfo.displayName.equals(displayName)) {
result.isAllowed = false;
result.errorMessage = getString(R.string.sftp_storage_preference_display_name_already_used);
break;
}
}
}
return result;
}
@NonNull
@Override
public StoragePreferenceDialogFragment.CallbackResult isUriAllowed(@NonNull Uri uri) {
StoragePreferenceDialogFragment.CallbackResult result = new StoragePreferenceDialogFragment.CallbackResult();
result.isAllowed = true;
for (SftpPlugin.StorageInfo storageInfo : storageInfoList) {
if (storageInfo.uri.equals(uri)) {
result.isAllowed = false;
result.errorMessage = getString(R.string.sftp_storage_preference_storage_location_already_configured);
break;
}
}
return result;
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
@Override
public void addNewStoragePreference(@NonNull SftpPlugin.StorageInfo storageInfo, int takeFlags) {
storageInfoList.add(storageInfo);
handleChangedStorageInfoList();
requireContext().getContentResolver().takePersistableUriPermission(storageInfo.uri, takeFlags);
}
private void handleChangedStorageInfoList() {
saveStorageInfoList();
preferenceCategory.removeAll();
addStoragePreferences(preferenceCategory);
Device device = BackgroundService.getInstance().getDevice(getDeviceId());
if (device != null) {
device.reloadPluginsFromSettings();
}
}
@Override
public boolean onPreferenceChange(Preference preference, Object newValue) {
SftpPlugin.StorageInfo newStorageInfo = (SftpPlugin.StorageInfo) newValue;
ListIterator<SftpPlugin.StorageInfo> it = storageInfoList.listIterator();
while (it.hasNext()) {
SftpPlugin.StorageInfo storageInfo = it.next();
if (storageInfo.uri.equals(newStorageInfo.uri)) {
it.set(newStorageInfo);
break;
}
}
handleChangedStorageInfoList();
return false;
}
@Override
public void onLongClick(StoragePreference storagePreference) {
if (actionMode == null) {
actionMode = ((DeviceSettingsActivity)requireActivity()).startSupportActionMode(this);
if (actionMode != null) {
for (int i = 0, count = preferenceCategory.getPreferenceCount(); i < count; i++) {
StoragePreference preference = (StoragePreference) preferenceCategory.getPreference(i);
preference.setSelectable(true);
if (storagePreference.equals(preference)) {
preference.checkbox.setChecked(true);
}
}
}
}
}
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
MenuInflater inflater = mode.getMenuInflater();
inflater.inflate(R.menu.sftp_settings_action_mode, menu);
return true;
}
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
return false;
}
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
switch (item.getItemId()) {
case R.id.delete:
for (int count = preferenceCategory.getPreferenceCount(), i = count - 1; i >= 0; i--) {
StoragePreference preference = (StoragePreference) preferenceCategory.getPreference(i);
if (preference.checkbox.isChecked()) {
SftpPlugin.StorageInfo info = storageInfoList.remove(i);
if (Build.VERSION.SDK_INT >= 21) {
requireContext().getContentResolver().releasePersistableUriPermission(info.uri, Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
}
}
}
actionMode.finish(); //This must be called before handleChangedStorageInfoList()
handleChangedStorageInfoList();
return true;
default:
return false;
}
}
@Override
public void onDestroyActionMode(ActionMode mode) {
actionMode = null;
for (int i = 0, count = preferenceCategory.getPreferenceCount(); i < count; i++) {
StoragePreference preference = (StoragePreference) preferenceCategory.getPreference(i);
preference.setSelectable(false);
preference.checkbox.setChecked(false);
}
}
}

View File

@ -21,15 +21,9 @@
package org.kde.kdeconnect.Plugins.SftpPlugin;
import android.content.Context;
import android.net.Uri;
import android.util.Log;
import org.apache.sshd.SshServer;
import org.apache.sshd.common.Session;
import org.apache.sshd.common.file.FileSystemFactory;
import org.apache.sshd.common.file.FileSystemView;
import org.apache.sshd.common.file.nativefs.NativeFileSystemView;
import org.apache.sshd.common.file.nativefs.NativeSshFile;
import org.apache.sshd.common.keyprovider.AbstractKeyPairProvider;
import org.apache.sshd.common.util.SecurityUtils;
import org.apache.sshd.server.PasswordAuthenticator;
@ -40,16 +34,10 @@ import org.apache.sshd.server.kex.DHG14;
import org.apache.sshd.server.session.ServerSession;
import org.apache.sshd.server.sftp.SftpSubsystem;
import org.kde.kdeconnect.Device;
import org.kde.kdeconnect.Helpers.MediaStoreHelper;
import org.kde.kdeconnect.Helpers.RandomHelper;
import org.kde.kdeconnect.Helpers.SecurityHelpers.RsaHelper;
import org.kde.kdeconnect.Helpers.SecurityHelpers.SslHelper;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.RandomAccessFile;
import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.NetworkInterface;
@ -62,6 +50,7 @@ import java.security.Security;
import java.util.Arrays;
import java.util.Collections;
import java.util.Enumeration;
import java.util.List;
class SimpleSftpServer {
private static final int STARTPORT = 1739;
@ -81,6 +70,7 @@ class SimpleSftpServer {
}
private final SshServer sshd = SshServer.setUpDefaultServer();
private AndroidFileSystemFactory fileSystemFactory;
void init(Context context, Device device) throws GeneralSecurityException {
@ -100,7 +90,8 @@ class SimpleSftpServer {
}
});
sshd.setFileSystemFactory(new AndroidFileSystemFactory(context));
fileSystemFactory = new AndroidFileSystemFactory(context);
sshd.setFileSystemFactory(fileSystemFactory);
sshd.setCommandFactory(new ScpCommandFactory());
sshd.setSubsystemFactories(Collections.singletonList(new SftpSubsystem.Factory()));
@ -113,9 +104,9 @@ class SimpleSftpServer {
sshd.setPasswordAuthenticator(passwordAuth);
}
public boolean start() {
public boolean start(List<SftpPlugin.StorageInfo> storageInfoList) {
if (!started) {
fileSystemFactory.initRoots(storageInfoList);
passwordAuth.password = RandomHelper.randomString(28);
port = STARTPORT;
@ -148,6 +139,10 @@ class SimpleSftpServer {
}
}
public boolean isStarted() {
return started;
}
String getPassword() {
return passwordAuth.password;
}
@ -189,120 +184,6 @@ class SimpleSftpServer {
return ip6;
}
static class AndroidFileSystemFactory implements FileSystemFactory {
final private Context context;
AndroidFileSystemFactory(Context context) {
this.context = context;
}
@Override
public FileSystemView createFileSystemView(final Session username) {
return new AndroidFileSystemView(username.getUsername(), context);
}
}
static class AndroidFileSystemView extends NativeFileSystemView {
final private Context context;
AndroidFileSystemView(final String userName, Context context) {
super(userName, true);
this.context = context;
}
// NativeFileSystemView.getFile(), NativeSshFile.getParentFile() and NativeSshFile.listSshFiles() call
// createNativeSshFile to create new NativeSshFiles so override that instead of getFile() to always create a AndroidSshFile
@Override
public AndroidSshFile createNativeSshFile(String name, File file, String username) {
return new AndroidSshFile(this, name, file, username, context);
}
}
static class AndroidSshFile extends NativeSshFile {
final private Context context;
final private File file;
AndroidSshFile(final AndroidFileSystemView view, String name, final File file, final String userName, Context context) {
super(view, name, file, userName);
this.context = context;
this.file = file;
}
@Override
public OutputStream createOutputStream(long offset) throws IOException {
if (!isWritable()) {
throw new IOException("No write permission : " + file.getName());
}
final RandomAccessFile raf = new RandomAccessFile(file, "rw");
try {
if (offset < raf.length()) {
throw new IOException("Your SSHFS is bugged"); //SSHFS 3.0 and 3.2 cause data corruption, abort the transfer if this happens
}
raf.setLength(offset);
raf.seek(offset);
return new FileOutputStream(raf.getFD()) {
public void close() throws IOException {
super.close();
raf.close();
}
};
} catch (IOException e) {
raf.close();
throw e;
}
}
@Override
public boolean delete() {
//Log.e("Sshd", "deleting file");
boolean ret = super.delete();
if (ret) {
MediaStoreHelper.indexFile(context, Uri.fromFile(file));
}
return ret;
}
@Override
public boolean create() throws IOException {
//Log.e("Sshd", "creating file");
boolean ret = super.create();
if (ret) {
MediaStoreHelper.indexFile(context, Uri.fromFile(file));
}
return ret;
}
// Based on https://github.com/wolpi/prim-ftpd/blob/master/primitiveFTPd/src/org/primftpd/filesystem/FsFile.java
@Override
public boolean doesExist() {
boolean exists = file.exists();
if (!exists) {
// file.exists() returns false when we don't have read permission
// try to figure out if it really does not exist
File parentFile = file.getParentFile();
File[] children = parentFile.listFiles();
if (children != null) {
for (File child : children) {
if (file.equals(child)) {
exists = true;
break;
}
}
}
}
return exists;
}
}
static class SimplePasswordAuthenticator implements PasswordAuthenticator {
String password;

View File

@ -0,0 +1,135 @@
/*
* Copyright 2018 Erik Duisters <e.duisters1@gmail.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License as
* published by the Free Software Foundation; either version 2 of
* the License or (at your option) version 3 or any later version
* accepted by the membership of KDE e.V. (or its successor approved
* by the membership of KDE e.V.), which shall act as a proxy
* defined in Section 14 of version 3 of the license.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.kde.kdeconnect.Plugins.SftpPlugin;
import android.content.Context;
import android.os.Build;
import android.provider.DocumentsContract;
import android.util.AttributeSet;
import android.view.View;
import android.widget.CheckBox;
import org.kde.kdeconnect_tp.R;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.preference.DialogPreference;
import androidx.preference.PreferenceViewHolder;
import butterknife.BindView;
import butterknife.ButterKnife;
public class StoragePreference extends DialogPreference {
@Nullable
private SftpPlugin.StorageInfo storageInfo;
@Nullable
private OnLongClickListener onLongClickListener;
@BindView(R.id.checkbox) CheckBox checkbox;
public StoragePreference(Context context, AttributeSet attrs) {
super(context, attrs);
setDialogLayoutResource(R.layout.fragment_storage_preference_dialog);
setWidgetLayoutResource(R.layout.preference_checkbox);
setPersistent(false);
setSelectable(false);
}
public StoragePreference(Context context) {
this(context, null);
}
public void setOnLongClickListener(@Nullable OnLongClickListener listener) {
this.onLongClickListener = listener;
}
public void setStorageInfo(@NonNull SftpPlugin.StorageInfo storageInfo) {
if (this.storageInfo != null && this.storageInfo.equals(storageInfo)) {
return;
}
if (callChangeListener(storageInfo)) {
setStorageInfoInternal(storageInfo);
}
}
private void setStorageInfoInternal(@NonNull SftpPlugin.StorageInfo storageInfo) {
this.storageInfo = storageInfo;
setTitle(storageInfo.displayName);
if (Build.VERSION.SDK_INT < 21) {
setSummary(storageInfo.uri.getPath());
} else {
setSummary(DocumentsContract.getTreeDocumentId(storageInfo.uri));
}
}
@Nullable
public SftpPlugin.StorageInfo getStorageInfo() {
return storageInfo;
}
@Override
public void setDefaultValue(Object defaultValue) {
if (defaultValue == null || defaultValue instanceof SftpPlugin.StorageInfo) {
super.setDefaultValue(defaultValue);
} else {
throw new RuntimeException("StoragePreference defaultValue must be null or an instance of StfpPlugin.StorageInfo");
}
}
@Override
protected void onSetInitialValue(@Nullable Object defaultValue) {
if (defaultValue != null) {
setStorageInfoInternal((SftpPlugin.StorageInfo) defaultValue);
}
}
@Override
public void onBindViewHolder(PreferenceViewHolder holder) {
super.onBindViewHolder(holder);
ButterKnife.bind(this, holder.itemView);
checkbox.setVisibility(isSelectable() ? View.VISIBLE : View.INVISIBLE);
holder.itemView.setOnLongClickListener(v -> {
if (onLongClickListener != null) {
onLongClickListener.onLongClick(StoragePreference.this);
return true;
}
return false;
});
}
@Override
protected void onClick() {
if (isSelectable()) {
checkbox.setChecked(!checkbox.isChecked());
} else {
super.onClick();
}
}
public interface OnLongClickListener {
void onLongClick(StoragePreference storagePreference);
}
}

View File

@ -0,0 +1,330 @@
package org.kde.kdeconnect.Plugins.SftpPlugin;
import android.annotation.TargetApi;
import android.app.Activity;
import android.app.Dialog;
import android.content.Intent;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.provider.DocumentsContract;
import android.text.Editable;
import android.text.InputFilter;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.view.View;
import android.widget.Button;
import com.google.android.material.textfield.TextInputEditText;
import com.google.android.material.textfield.TextInputLayout;
import org.json.JSONException;
import org.json.JSONObject;
import org.kde.kdeconnect.Helpers.StorageHelper;
import org.kde.kdeconnect_tp.R;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.graphics.drawable.DrawableCompat;
import androidx.preference.PreferenceDialogFragmentCompat;
import butterknife.BindView;
import butterknife.ButterKnife;
import butterknife.OnClick;
import butterknife.Unbinder;
public class StoragePreferenceDialogFragment extends PreferenceDialogFragmentCompat implements TextWatcher {
private static final int REQUEST_CODE_DOCUMENT_TREE = 1001;
//When state is restored I cannot determine if an error is going to be displayed on one of the TextInputEditText's or not so I have to remember if the dialog's positive button was enabled or not
private static final String KEY_POSITIVE_BUTTON_ENABLED = "PositiveButtonEnabled";
private static final String KEY_STORAGE_INFO = "StorageInfo";
private static final String KEY_TAKE_FLAGS = "TakeFlags";
@BindView(R.id.storageLocation) TextInputEditText storageLocation;
@BindView(R.id.storageDisplayName) TextInputEditText storageDisplayName;
@BindView(R.id.storageDisplayNameInputLayout) TextInputLayout storageDisplayInputLayout;
private Unbinder unbinder;
private Callback callback;
private Drawable arrowDropDownDrawable;
private Button positiveButton;
private boolean stateRestored;
private boolean enablePositiveButton;
private SftpPlugin.StorageInfo storageInfo;
private int takeFlags;
public static StoragePreferenceDialogFragment newInstance(String key) {
StoragePreferenceDialogFragment fragment = new StoragePreferenceDialogFragment();
Bundle args = new Bundle();
args.putString(ARG_KEY, key);
fragment.setArguments(args);
return fragment;
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
stateRestored = false;
enablePositiveButton = true;
if (savedInstanceState != null) {
stateRestored = true;
enablePositiveButton = savedInstanceState.getBoolean(KEY_POSITIVE_BUTTON_ENABLED);
takeFlags = savedInstanceState.getInt(KEY_TAKE_FLAGS, 0);
try {
JSONObject jsonObject = new JSONObject(savedInstanceState.getString(KEY_STORAGE_INFO, "{}"));
storageInfo = SftpPlugin.StorageInfo.fromJSON(jsonObject);
} catch (JSONException ignored) {}
}
Drawable drawable = AppCompatResources.getDrawable(requireContext(), R.drawable.ic_arrow_drop_down_24px);
if (drawable != null) {
drawable = DrawableCompat.wrap(drawable);
DrawableCompat.setTint(drawable, getResources().getColor(android.R.color.darker_gray));
drawable.setBounds(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight());
arrowDropDownDrawable = drawable;
}
}
void setCallback(Callback callback) {
this.callback = callback;
}
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
AlertDialog dialog = (AlertDialog) super.onCreateDialog(savedInstanceState);
dialog.setOnShowListener(dialog1 -> {
AlertDialog alertDialog = (AlertDialog) dialog1;
positiveButton = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE);
positiveButton.setEnabled(enablePositiveButton);
});
return dialog;
}
@Override
protected void onBindDialogView(View view) {
super.onBindDialogView(view);
unbinder = ButterKnife.bind(this, view);
storageDisplayName.setFilters(new InputFilter[]{new FileSeparatorCharFilter()});
storageDisplayName.addTextChangedListener(this);
if (getPreference().getKey().equals(getString(R.string.sftp_preference_key_add_storage))) {
if (!stateRestored) {
enablePositiveButton = false;
storageLocation.setText(requireContext().getString(R.string.sftp_storage_preference_click_to_select));
}
boolean isClickToSelect = storageLocation.getText() != null && storageLocation.getText().toString().equals(getString(R.string.sftp_storage_preference_click_to_select));
storageLocation.setCompoundDrawables(null, null, isClickToSelect ? arrowDropDownDrawable : null, null);
storageLocation.setEnabled(isClickToSelect);
storageLocation.setFocusable(false);
storageLocation.setFocusableInTouchMode(false);
storageDisplayName.setEnabled(!isClickToSelect);
} else {
if (!stateRestored) {
StoragePreference preference = (StoragePreference) getPreference();
SftpPlugin.StorageInfo info = preference.getStorageInfo();
if (info == null) {
throw new RuntimeException("Cannot edit a StoragePreference that does not have its storageInfo set");
}
storageInfo = SftpPlugin.StorageInfo.copy(info);
if (Build.VERSION.SDK_INT < 21) {
storageLocation.setText(storageInfo.uri.getPath());
} else {
storageLocation.setText(DocumentsContract.getTreeDocumentId(storageInfo.uri));
}
storageDisplayName.setText(storageInfo.displayName);
}
storageLocation.setCompoundDrawables(null, null, null, null);
storageLocation.setEnabled(false);
storageLocation.setFocusable(false);
storageLocation.setFocusableInTouchMode(false);
storageDisplayName.setEnabled(true);
}
}
@Override
public void onDestroyView() {
super.onDestroyView();
unbinder.unbind();
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
@OnClick(R.id.storageLocation)
void onSelectStorageClicked() {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
//For API >= 26 we can also set Extra: DocumentsContract.EXTRA_INITIAL_URI
startActivityForResult(intent, REQUEST_CODE_DOCUMENT_TREE);
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (resultCode != Activity.RESULT_OK) {
return;
}
switch (requestCode) {
case REQUEST_CODE_DOCUMENT_TREE:
Uri uri = data.getData();
takeFlags = data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
if (uri == null) {
return;
}
CallbackResult result = callback.isUriAllowed(uri);
if (result.isAllowed) {
String documentId = DocumentsContract.getTreeDocumentId(uri);
String displayName = StorageHelper.getDisplayName(requireContext(), uri);
storageInfo = new SftpPlugin.StorageInfo(displayName, uri);
storageLocation.setText(documentId);
storageLocation.setCompoundDrawables(null, null, null, null);
storageLocation.setError(null);
storageLocation.setEnabled(false);
// TODO: Show name as used in android's picker app but I don't think it's possible to get that, everything I tried throws PermissionDeniedException
storageDisplayName.setText(displayName);
storageDisplayName.setEnabled(true);
} else {
storageLocation.setError(result.errorMessage);
setPositiveButtonEnabled(false);
}
break;
}
}
@Override
public void onSaveInstanceState(@NonNull Bundle outState) {
super.onSaveInstanceState(outState);
outState.putBoolean(KEY_POSITIVE_BUTTON_ENABLED, positiveButton.isEnabled());
outState.putInt(KEY_TAKE_FLAGS, takeFlags);
if (storageInfo != null) {
try {
outState.putString(KEY_STORAGE_INFO, storageInfo.toJSON().toString());
} catch (JSONException ignored) {}
}
}
@Override
public void onDialogClosed(boolean positiveResult) {
if (positiveResult) {
storageInfo.displayName = storageDisplayName.getText().toString();
if (getPreference().getKey().equals(getString(R.string.sftp_preference_key_add_storage))) {
callback.addNewStoragePreference(storageInfo, takeFlags);
} else {
((StoragePreference)getPreference()).setStorageInfo(storageInfo);
}
}
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
//Don't care
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
//Don't care
}
@Override
public void afterTextChanged(Editable s) {
String displayName = s.toString();
StoragePreference storagePreference = (StoragePreference) getPreference();
SftpPlugin.StorageInfo storageInfo = storagePreference.getStorageInfo();
if (storageInfo == null || !storageInfo.displayName.equals(displayName)) {
CallbackResult result = callback.isDisplayNameAllowed(displayName);
if (result.isAllowed) {
setPositiveButtonEnabled(true);
} else {
setPositiveButtonEnabled(false);
storageDisplayName.setError(result.errorMessage);
}
}
}
private void setPositiveButtonEnabled(boolean enabled) {
if (positiveButton != null) {
positiveButton.setEnabled(enabled);
} else {
enablePositiveButton = enabled;
}
}
private class FileSeparatorCharFilter implements InputFilter {
//TODO: Add more chars to refuse?
//https://www.cyberciti.biz/faq/linuxunix-rules-for-naming-file-and-directory-names/
String notAllowed = "/\\><|:&?*";
@Override
public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) {
boolean keepOriginal = true;
StringBuilder sb = new StringBuilder(end - start);
for (int i = start; i < end; i++) {
char c = source.charAt(i);
if (notAllowed.indexOf(c) < 0) {
sb.append(c);
} else {
keepOriginal = false;
sb.append("_");
}
}
if (keepOriginal) {
return null;
} else {
if (source instanceof Spanned) {
SpannableString sp = new SpannableString(sb);
TextUtils.copySpansFrom((Spanned) source, start, sb.length(), null, sp, 0);
return sp;
} else {
return sb;
}
}
}
}
static class CallbackResult {
boolean isAllowed;
String errorMessage;
}
interface Callback {
@NonNull CallbackResult isDisplayNameAllowed(@NonNull String displayName);
@NonNull CallbackResult isUriAllowed(@NonNull Uri uri);
void addNewStoragePreference(@NonNull SftpPlugin.StorageInfo storageInfo, int takeFlags);
}
}

View File

@ -26,17 +26,14 @@ import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.ClipboardManager;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.res.Resources;
import android.database.Cursor;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.provider.MediaStore;
import android.util.Log;
import android.widget.Toast;
@ -48,8 +45,6 @@ import org.kde.kdeconnect.Plugins.PluginFactory;
import org.kde.kdeconnect.UserInterface.PluginSettingsFragment;
import org.kde.kdeconnect_tp.R;
import java.io.File;
import java.io.InputStream;
import java.net.URL;
import java.util.ArrayList;
import java.util.concurrent.ExecutorService;

View File

@ -16,7 +16,7 @@
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
*/
package org.kde.kdeconnect.UserInterface;
@ -131,4 +131,8 @@ public class DeviceSettingsActivity
public void onFinish() {
finish();
}
public String getDeviceId() {
return deviceId;
}
}

View File

@ -0,0 +1,87 @@
/*
* Copyright 2019 Erik Duisters <e.duisters1@gmail.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License as
* published by the Free Software Foundation; either version 2 of
* the License or (at your option) version 3 or any later version
* accepted by the membership of KDE e.V. (or its successor approved
* by the membership of KDE e.V.), which shall act as a proxy
* defined in Section 14 of version 3 of the license.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.kde.kdeconnect.UserInterface;
import android.content.Intent;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
public class DeviceSettingsAlertDialogFragment extends AlertDialogFragment {
private static final String KEY_PLUGIN_KEY = "PluginKey";
private static final String KEY_DEVICE_ID = "DeviceId";
private String pluginKey;
private String deviceId;
public DeviceSettingsAlertDialogFragment() {}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Bundle args = getArguments();
if (args == null || !args.containsKey(KEY_PLUGIN_KEY)) {
throw new RuntimeException("You must call Builder.setPluginKey() to set the plugin");
}
if (!args.containsKey(KEY_DEVICE_ID)) {
throw new RuntimeException("You must call Builder.setDeviceId() to set the device");
}
pluginKey = args.getString(KEY_PLUGIN_KEY);
deviceId = args.getString(KEY_DEVICE_ID);
setCallback(new Callback() {
@Override
public void onPositiveButtonClicked() {
Intent intent = new Intent(requireActivity(), DeviceSettingsActivity.class);
intent.putExtra(DeviceSettingsActivity.EXTRA_DEVICE_ID, deviceId);
intent.putExtra(DeviceSettingsActivity.EXTRA_PLUGIN_KEY, pluginKey);
requireActivity().startActivity(intent);
}
});
}
public static class Builder extends AbstractBuilder<DeviceSettingsAlertDialogFragment.Builder, DeviceSettingsAlertDialogFragment> {
@Override
public DeviceSettingsAlertDialogFragment.Builder getThis() {
return this;
}
public DeviceSettingsAlertDialogFragment.Builder setPluginKey(String pluginKey) {
args.putString(KEY_PLUGIN_KEY, pluginKey);
return getThis();
}
public DeviceSettingsAlertDialogFragment.Builder setDeviceId(String deviceId) {
args.putString(KEY_DEVICE_ID, deviceId);
return getThis();
}
@Override
protected DeviceSettingsAlertDialogFragment createFragment() {
return new DeviceSettingsAlertDialogFragment();
}
}
}

View File

@ -100,6 +100,7 @@ public class DeviceSettingsFragment extends PreferenceFragmentCompat {
List<String> plugins = device.getSupportedPlugins();
for (final String pluginKey : plugins) {
//TODO: Use PreferenceManagers context
PluginPreference pref = new PluginPreference(requireContext(), pluginKey, device, callback);
preferenceScreen.addPreference(pref);
}

View File

@ -78,4 +78,8 @@ public class PluginSettingsFragment extends PreferenceFragmentCompat {
PluginFactory.PluginInfo info = PluginFactory.getPluginInfo(pluginKey);
requireActivity().setTitle(getString(R.string.plugin_settings_with_name, info.getDisplayName()));
}
public String getDeviceId() {
return ((DeviceSettingsActivity)requireActivity()).getDeviceId();
}
}