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:
parent
f48b5612c7
commit
a6fdddf843
@ -65,6 +65,11 @@ dependencies {
|
|||||||
repositories {
|
repositories {
|
||||||
jcenter()
|
jcenter()
|
||||||
google()
|
google()
|
||||||
|
/* Needed for org.apache.sshd debugging
|
||||||
|
maven {
|
||||||
|
url "https://jitpack.io"
|
||||||
|
}
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
implementation 'androidx.media:media:1.0.1'
|
implementation 'androidx.media:media:1.0.1'
|
||||||
@ -77,6 +82,7 @@ dependencies {
|
|||||||
implementation 'org.apache.sshd:sshd-core:0.14.0'
|
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 '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.madgag.spongycastle:bcpkix-jdk15on:1.58.0.0' //For SSL certificate generation
|
||||||
|
|
||||||
implementation 'com.jakewharton:butterknife:10.0.0'
|
implementation 'com.jakewharton:butterknife:10.0.0'
|
||||||
|
9
res/drawable/ic_arrow_drop_down_24px.xml
Normal file
9
res/drawable/ic_arrow_drop_down_24px.xml
Normal 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>
|
@ -1,9 +1,7 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
android:height="24dp"
|
||||||
|
android:width="24dp"
|
||||||
android:viewportWidth="24"
|
android:viewportWidth="24"
|
||||||
android:viewportHeight="24">
|
android:viewportHeight="24">
|
||||||
<path
|
<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" />
|
||||||
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"/>
|
|
||||||
</vector>
|
</vector>
|
||||||
|
58
res/layout/fragment_storage_preference_dialog.xml
Normal file
58
res/layout/fragment_storage_preference_dialog.xml
Normal 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>
|
9
res/layout/preference_checkbox.xml
Normal file
9
res/layout/preference_checkbox.xml
Normal 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"/>
|
10
res/menu/sftp_settings_action_mode.xml
Normal file
10
res/menu/sftp_settings_action_mode.xml
Normal 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>
|
@ -225,13 +225,37 @@
|
|||||||
<string name="title_activity_notification_filter">Notification filter</string>
|
<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="filter_apps_info">Notifications will be synchronized for the selected apps.</string>
|
||||||
<string name="sftp_internal_storage">Internal storage</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_num">SD card %d</string>
|
||||||
<string name="sftp_sdcard">SD card</string>
|
<string name="sftp_sdcard">SD card</string>
|
||||||
<string name="sftp_readonly">(read only)</string>
|
<string name="sftp_readonly">(read only)</string>
|
||||||
<string name="sftp_camera">Camera pictures</string>
|
<string name="sftp_camera">Camera pictures</string>
|
||||||
<string name="add_device_dialog_title">Add device</string>
|
<string name="add_device_dialog_title">Add device</string>
|
||||||
<string name="add_device_hint">Hostname or IP address</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="no_players_connected">No players found</string>
|
||||||
<string name="mpris_player_on_device">%1$s on %2$s</string>
|
<string name="mpris_player_on_device">%1$s on %2$s</string>
|
||||||
<string name="send_files">Send files</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="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="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="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="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="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>
|
<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>
|
||||||
|
@ -18,6 +18,7 @@
|
|||||||
<item name="android:textColorPrimary">@android:color/black</item>
|
<item name="android:textColorPrimary">@android:color/black</item>
|
||||||
<item name="android:textColor">@android:color/black</item>
|
<item name="android:textColor">@android:color/black</item>
|
||||||
<item name="preferenceTheme">@style/PreferenceThemeOverlay</item>
|
<item name="preferenceTheme">@style/PreferenceThemeOverlay</item>
|
||||||
|
<item name="actionModeStyle">@style/ActionModeStyle</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style name="KdeConnectThemeBase.NoActionBar" parent="KdeConnectThemeBase">
|
<style name="KdeConnectThemeBase.NoActionBar" parent="KdeConnectThemeBase">
|
||||||
@ -42,4 +43,8 @@
|
|||||||
<style name="DisableableButton" parent="ThemeOverlay.AppCompat">
|
<style name="DisableableButton" parent="ThemeOverlay.AppCompat">
|
||||||
<item name="colorButtonNormal">@drawable/disableable_button</item>
|
<item name="colorButtonNormal">@drawable/disableable_button</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<style name="ActionModeStyle" parent="Widget.AppCompat.ActionMode">
|
||||||
|
<item name="background">@color/primaryDark</item>
|
||||||
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
22
res/xml/sftpplugin_preferences.xml
Normal file
22
res/xml/sftpplugin_preferences.xml
Normal 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>
|
@ -20,7 +20,12 @@
|
|||||||
|
|
||||||
package org.kde.kdeconnect.Helpers;
|
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.os.Environment;
|
||||||
|
import android.provider.DocumentsContract;
|
||||||
|
|
||||||
import java.io.BufferedReader;
|
import java.io.BufferedReader;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
@ -32,6 +37,8 @@ import java.util.List;
|
|||||||
import java.util.Scanner;
|
import java.util.Scanner;
|
||||||
import java.util.StringTokenizer;
|
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
|
//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
|
//modified to work on Lollipop and other devices
|
||||||
public class StorageHelper {
|
public class StorageHelper {
|
||||||
@ -43,7 +50,7 @@ public class StorageHelper {
|
|||||||
public final boolean removable;
|
public final boolean removable;
|
||||||
public final int number;
|
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.path = path;
|
||||||
this.readonly = readonly;
|
this.readonly = readonly;
|
||||||
this.removable = removable;
|
this.removable = removable;
|
||||||
@ -77,7 +84,7 @@ public class StorageHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
File storage = new File("/storage/");
|
File storage = new File("/storage/");
|
||||||
if (storage.exists() && storage.isDirectory()) {
|
if (storage.exists() && storage.isDirectory() && storage.canRead()) {
|
||||||
String mounts = null;
|
String mounts = null;
|
||||||
try (Scanner scanner = new Scanner(new File("/proc/mounts"))) {
|
try (Scanner scanner = new Scanner(new File("/proc/mounts"))) {
|
||||||
mounts = scanner.useDelimiter("\\A").next();
|
mounts = scanner.useDelimiter("\\A").next();
|
||||||
@ -100,7 +107,7 @@ public class StorageHelper {
|
|||||||
if (!path.startsWith("/storage/emulated") || dirs.length == 1) {
|
if (!path.startsWith("/storage/emulated") || dirs.length == 1) {
|
||||||
if (!paths.contains(path) && !paths.contains(path2)) {
|
if (!paths.contains(path) && !paths.contains(path2)) {
|
||||||
if (mounts == null || mounts.contains(path) || mounts.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);
|
paths.add(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -153,4 +160,37 @@ public class StorageHelper {
|
|||||||
return list;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
499
src/org/kde/kdeconnect/Plugins/SftpPlugin/AndroidSafSshFile.java
Normal file
499
src/org/kde/kdeconnect/Plugins/SftpPlugin/AndroidSafSshFile.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
114
src/org/kde/kdeconnect/Plugins/SftpPlugin/AndroidSshFile.java
Normal file
114
src/org/kde/kdeconnect/Plugins/SftpPlugin/AndroidSshFile.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
178
src/org/kde/kdeconnect/Plugins/SftpPlugin/RootFile.java
Normal file
178
src/org/kde/kdeconnect/Plugins/SftpPlugin/RootFile.java
Normal 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() {
|
||||||
|
}
|
||||||
|
}
|
@ -15,33 +15,47 @@
|
|||||||
* GNU General Public License for more details.
|
* GNU General Public License for more details.
|
||||||
*
|
*
|
||||||
* You should have received a copy of the GNU General Public License
|
* You should have received a copy of the GNU General Public License
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.kde.kdeconnect.Plugins.SftpPlugin;
|
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.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.NetworkPacket;
|
||||||
import org.kde.kdeconnect.Plugins.Plugin;
|
import org.kde.kdeconnect.Plugins.Plugin;
|
||||||
import org.kde.kdeconnect.Plugins.PluginFactory;
|
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 org.kde.kdeconnect_tp.R;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Iterator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.preference.PreferenceManager;
|
||||||
|
|
||||||
@PluginFactory.LoadablePlugin
|
@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 = "kdeconnect.sftp";
|
||||||
private final static String PACKET_TYPE_SFTP_REQUEST = "kdeconnect.sftp.request";
|
private final static String PACKET_TYPE_SFTP_REQUEST = "kdeconnect.sftp.request";
|
||||||
|
|
||||||
private static final SimpleSftpServer server = new SimpleSftpServer();
|
private static final SimpleSftpServer server = new SimpleSftpServer();
|
||||||
|
|
||||||
|
private String KeyStorageInfoList;
|
||||||
|
private String KeyAddCameraShortcut;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getDisplayName() {
|
public String getDisplayName() {
|
||||||
return context.getResources().getString(R.string.pref_plugin_sftp);
|
return context.getResources().getString(R.string.pref_plugin_sftp);
|
||||||
@ -54,9 +68,16 @@ public class SftpPlugin extends Plugin {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean onCreate() {
|
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 {
|
try {
|
||||||
server.init(context, device);
|
server.init(context, device);
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) {
|
||||||
|
return SftpSettingsFragment.getStorageInfoList(context).size() != 0;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
e.printStackTrace();
|
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
|
@Override
|
||||||
public void onDestroy() {
|
public void onDestroy() {
|
||||||
server.stop();
|
server.stop();
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(context).unregisterOnSharedPreferenceChangeListener(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean onPacketReceived(NetworkPacket np) {
|
public boolean onPacketReceived(NetworkPacket np) {
|
||||||
|
|
||||||
if (np.getBoolean("startBrowsing")) {
|
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);
|
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("ip", server.getLocalIpAddress());
|
||||||
np2.set("port", server.getPort());
|
np2.set("port", server.getPort());
|
||||||
np2.set("user", SimpleSftpServer.USER);
|
np2.set("user", SimpleSftpServer.USER);
|
||||||
np2.set("password", server.getPassword());
|
np2.set("password", server.getPassword());
|
||||||
|
|
||||||
//Kept for compatibility, in case "multiPaths" is not possible or the other end does not support it
|
//Kept for compatibility, in case "multiPaths" is not possible or the other end does not support it
|
||||||
np2.set("path", Environment.getExternalStorageDirectory().getAbsolutePath());
|
np2.set("path", "/");
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (paths.size() > 0) {
|
if (paths.size() > 0) {
|
||||||
np2.set("multiPaths", paths);
|
np2.set("multiPaths", paths);
|
||||||
np2.set("pathNames", pathNames);
|
np2.set("pathNames", pathNames);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
device.sendPacket(np2);
|
device.sendPacket(np2);
|
||||||
@ -137,12 +166,66 @@ public class SftpPlugin extends Plugin {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
private void getPathsAndNamesForStorageInfoList(List<String> paths, List<String> pathNames, List<StorageInfo> storageInfoList) {
|
||||||
public String[] getRequiredPermissions() {
|
StorageInfo prevInfo = null;
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
|
StringBuilder pathBuilder = new StringBuilder();
|
||||||
return new String[]{Manifest.permission.READ_EXTERNAL_STORAGE};
|
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||||||
} else {
|
boolean addCameraShortcuts = false;
|
||||||
return new String[0];
|
|
||||||
|
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 {
|
||||||
|
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};
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -21,15 +21,9 @@
|
|||||||
package org.kde.kdeconnect.Plugins.SftpPlugin;
|
package org.kde.kdeconnect.Plugins.SftpPlugin;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.net.Uri;
|
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import org.apache.sshd.SshServer;
|
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.keyprovider.AbstractKeyPairProvider;
|
||||||
import org.apache.sshd.common.util.SecurityUtils;
|
import org.apache.sshd.common.util.SecurityUtils;
|
||||||
import org.apache.sshd.server.PasswordAuthenticator;
|
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.session.ServerSession;
|
||||||
import org.apache.sshd.server.sftp.SftpSubsystem;
|
import org.apache.sshd.server.sftp.SftpSubsystem;
|
||||||
import org.kde.kdeconnect.Device;
|
import org.kde.kdeconnect.Device;
|
||||||
import org.kde.kdeconnect.Helpers.MediaStoreHelper;
|
|
||||||
import org.kde.kdeconnect.Helpers.RandomHelper;
|
import org.kde.kdeconnect.Helpers.RandomHelper;
|
||||||
import org.kde.kdeconnect.Helpers.SecurityHelpers.RsaHelper;
|
import org.kde.kdeconnect.Helpers.SecurityHelpers.RsaHelper;
|
||||||
import org.kde.kdeconnect.Helpers.SecurityHelpers.SslHelper;
|
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.Inet4Address;
|
||||||
import java.net.InetAddress;
|
import java.net.InetAddress;
|
||||||
import java.net.NetworkInterface;
|
import java.net.NetworkInterface;
|
||||||
@ -62,6 +50,7 @@ import java.security.Security;
|
|||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Enumeration;
|
import java.util.Enumeration;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
class SimpleSftpServer {
|
class SimpleSftpServer {
|
||||||
private static final int STARTPORT = 1739;
|
private static final int STARTPORT = 1739;
|
||||||
@ -81,6 +70,7 @@ class SimpleSftpServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private final SshServer sshd = SshServer.setUpDefaultServer();
|
private final SshServer sshd = SshServer.setUpDefaultServer();
|
||||||
|
private AndroidFileSystemFactory fileSystemFactory;
|
||||||
|
|
||||||
void init(Context context, Device device) throws GeneralSecurityException {
|
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.setCommandFactory(new ScpCommandFactory());
|
||||||
sshd.setSubsystemFactories(Collections.singletonList(new SftpSubsystem.Factory()));
|
sshd.setSubsystemFactories(Collections.singletonList(new SftpSubsystem.Factory()));
|
||||||
|
|
||||||
@ -113,9 +104,9 @@ class SimpleSftpServer {
|
|||||||
sshd.setPasswordAuthenticator(passwordAuth);
|
sshd.setPasswordAuthenticator(passwordAuth);
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean start() {
|
public boolean start(List<SftpPlugin.StorageInfo> storageInfoList) {
|
||||||
if (!started) {
|
if (!started) {
|
||||||
|
fileSystemFactory.initRoots(storageInfoList);
|
||||||
passwordAuth.password = RandomHelper.randomString(28);
|
passwordAuth.password = RandomHelper.randomString(28);
|
||||||
|
|
||||||
port = STARTPORT;
|
port = STARTPORT;
|
||||||
@ -148,6 +139,10 @@ class SimpleSftpServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isStarted() {
|
||||||
|
return started;
|
||||||
|
}
|
||||||
|
|
||||||
String getPassword() {
|
String getPassword() {
|
||||||
return passwordAuth.password;
|
return passwordAuth.password;
|
||||||
}
|
}
|
||||||
@ -189,120 +184,6 @@ class SimpleSftpServer {
|
|||||||
return ip6;
|
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 {
|
static class SimplePasswordAuthenticator implements PasswordAuthenticator {
|
||||||
|
|
||||||
String password;
|
String password;
|
||||||
|
135
src/org/kde/kdeconnect/Plugins/SftpPlugin/StoragePreference.java
Normal file
135
src/org/kde/kdeconnect/Plugins/SftpPlugin/StoragePreference.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -26,17 +26,14 @@ import android.app.Notification;
|
|||||||
import android.app.NotificationManager;
|
import android.app.NotificationManager;
|
||||||
import android.app.PendingIntent;
|
import android.app.PendingIntent;
|
||||||
import android.content.ClipboardManager;
|
import android.content.ClipboardManager;
|
||||||
import android.content.ContentResolver;
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.res.Resources;
|
import android.content.res.Resources;
|
||||||
import android.database.Cursor;
|
|
||||||
import android.graphics.drawable.Drawable;
|
import android.graphics.drawable.Drawable;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
import android.provider.MediaStore;
|
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
|
|
||||||
@ -48,8 +45,6 @@ import org.kde.kdeconnect.Plugins.PluginFactory;
|
|||||||
import org.kde.kdeconnect.UserInterface.PluginSettingsFragment;
|
import org.kde.kdeconnect.UserInterface.PluginSettingsFragment;
|
||||||
import org.kde.kdeconnect_tp.R;
|
import org.kde.kdeconnect_tp.R;
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
|
@ -15,8 +15,8 @@
|
|||||||
* GNU General Public License for more details.
|
* GNU General Public License for more details.
|
||||||
*
|
*
|
||||||
* You should have received a copy of the GNU General Public License
|
* You should have received a copy of the GNU General Public License
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.kde.kdeconnect.UserInterface;
|
package org.kde.kdeconnect.UserInterface;
|
||||||
|
|
||||||
@ -131,4 +131,8 @@ public class DeviceSettingsActivity
|
|||||||
public void onFinish() {
|
public void onFinish() {
|
||||||
finish();
|
finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getDeviceId() {
|
||||||
|
return deviceId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -100,6 +100,7 @@ public class DeviceSettingsFragment extends PreferenceFragmentCompat {
|
|||||||
List<String> plugins = device.getSupportedPlugins();
|
List<String> plugins = device.getSupportedPlugins();
|
||||||
|
|
||||||
for (final String pluginKey : plugins) {
|
for (final String pluginKey : plugins) {
|
||||||
|
//TODO: Use PreferenceManagers context
|
||||||
PluginPreference pref = new PluginPreference(requireContext(), pluginKey, device, callback);
|
PluginPreference pref = new PluginPreference(requireContext(), pluginKey, device, callback);
|
||||||
preferenceScreen.addPreference(pref);
|
preferenceScreen.addPreference(pref);
|
||||||
}
|
}
|
||||||
|
@ -78,4 +78,8 @@ public class PluginSettingsFragment extends PreferenceFragmentCompat {
|
|||||||
PluginFactory.PluginInfo info = PluginFactory.getPluginInfo(pluginKey);
|
PluginFactory.PluginInfo info = PluginFactory.getPluginInfo(pluginKey);
|
||||||
requireActivity().setTitle(getString(R.string.plugin_settings_with_name, info.getDisplayName()));
|
requireActivity().setTitle(getString(R.string.plugin_settings_with_name, info.getDisplayName()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getDeviceId() {
|
||||||
|
return ((DeviceSettingsActivity)requireActivity()).getDeviceId();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user