diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 767a663a..8e7fc2db 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -1,263 +1,264 @@
+
\ No newline at end of file
diff --git a/res/drawable/ic_camera.xml b/res/drawable/ic_camera.xml
new file mode 100644
index 00000000..d8aa8944
--- /dev/null
+++ b/res/drawable/ic_camera.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 4fe02cf2..2d7fb73b 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -1,331 +1,333 @@
KDE Connect
Not connected to any device
Connected to: %s
Telephony notifier
Send notifications for incoming calls
Battery report
Periodically report battery status
Filesystem expose
Allows to browse this device\'s filesystem remotely
Clipboard sync
Share the clipboard content
Remote input
Use your phone or tablet as a touchpad and keyboard
Slideshow remote
Use your device to change slides in a presentation
Receive remote keypresses
Receive keypress events from remote devices
Multimedia controls
Provides a remote control for your media player
Run Command
Trigger remote commands from your phone or tablet
Contacts Synchronizer
Allow synchronizing the device\'s contacts book
Ping
Send and receive pings
Notification sync
Access your notifications from other devices
Receive notifications
Receive notifications from the other device and display them on Android
Share and receive
Share files and URLs between devices
This feature is not available in your Android version
No devices
OK
Cancel
Open settings
You need to grant permission to access notifications
To be able to control your media players you need to grant access to the notifications
Send ping
Multimedia control
remotekeyboard_editing_only
Handle remote keys only when editing
There is no active remote keyboard connection, establish one in kdeconnect
Remote keyboard connection is active
There is more than one remote keyboard connection, select the device to configure
Remote input
Move a finger on the screen to move the mouse cursor. Tap for a click, and use two/three fingers for right and middle buttons. Use 2 fingers to scroll. Use a long press to drag\'n drop.
Set two finger tap action
Set three finger tap action
Set touchpad sensitivity
Set pointer acceleration
mousepad_double_tap_key
mousepad_triple_tap_key
mousepad_sensitivity_key
mousepad_acceleration_profile_key
Reverse Scrolling Direction
mousepad_scroll_direction
- Right click
- Middle click
- Nothing
right
middle
default
medium
- right
- middle
- none
- Slowest
- Above Slowest
- Default
- Above Default
- Fastest
- No Acceleration
- Weakest
- Weaker
- Medium
- Stronger
- Strongest
- slowest
- aboveSlowest
- default
- aboveDefault
- fastest
- noacceleration
- weaker
- weak
- medium
- strong
- stronger
Connected devices
Available devices
Remembered devices
Plugin settings
Unpair
Paired device not reachable
Pair new device
Unknown device
Device not reachable
Pairing already requested
Device already paired
Could not send package
Timed out
Canceled by user
Canceled by other peer
Invalid key received
Encryption Info
The other device doesn\'t use a recent version of KDE Connect, using the legacy encryption method.
SHA1 fingerprint of your device certificate is:
SHA1 fingerprint of remote device certificate is:
Pair requested
Pairing request from %1s
Received link from %1s
Tap to open \'%1s\'
Receiving file from %1s>
- Receiving %1$d file from %2$s
- Receiving %1$d files from %2$s
- File: %1s
- (File %2$d of %3$d) : %1$s
Sending file to %1s
Sending files to %1s
- Sent %1$d file
- Sent %1$d out of %2$d files
- Received file from %1$s
- Received %2$d files from %1$s
- Failed receiving file from %1$s
- Failed receiving %2$d of %3$d files from %1$s
Tap to open \'%1s\'
Cannot create file %s
Sent file to %1s
%1s
Failed to send file to %1s
%1s
Tap to answer
Reconnect
Send Right Click
Send Middle Click
Show Keyboard
Device not paired
Request pairing
Accept
Reject
Device
Pair device
Settings
Play
Pause
Previous
Rewind
Fast-forward
Next
Volume
Multimedia Settings
Forward/rewind buttons
Adjust the time to fast forward/rewind when pressed
mpris_interval_time
- 10 seconds
- 20 seconds
- 30 seconds
- 1 minute
- 2 minutes
10000000
- 10000000
- 20000000
- 30000000
- 60000000
- 120000000
Show media control notification
Allow controlling your media players without opening KDE Connect
mpris_notification_enabled
Share To…
This device uses an old protocol version
This device uses a newer protocol version
General Settings
Settings
%s settings
Device name
%s
Invalid device name
Received text, saved to clipboard
Custom device list
Pair a new device
Unpair %s
Add devices by IP
Delete %s?
Custom device deleted
If your device is not automatically detected you can add its IP address or hostname by clicking on the Floating Action Button
Add a device
Undo
Noisy notifications
Vibrate and play a sound when receiving a file
Customize destination directory
Received files will appear in Downloads
Files will be stored in the directory below
Destination directory
Share
Share \"%s\"
Notification filter
Notifications will be synchronized for the selected apps.
Internal storage
All files
SD card %d
SD card
(read only)
Camera pictures
Add device
Hostname or IP address
No players found
%1$s on %2$s
Send files
KDE Connect Devices
Other devices running KDE Connect in your same network should appear here.
Device paired
Rename device
Rename
Refresh
This paired device is not reachable. Make sure it is connected to your same network.
It looks like you are on a mobile data connection. KDE Connect only works on local networks.
There are no file browsers installed.
Send SMS
Send text messages from your desktop
This plugin is not supported by the device
Find my phone
Find my tablet
Find my TV
Rings this device so you can find it
Found
Open
Close
You need to grant permissions to access the storage
Some Plugins need permissions to work (tap for more info):
This plugin needs permissions to work
You need to grant extra permissions to enable all functions
Some plugins have features disabled because of lack of permission (tap for more info):
To access your files from your PC the app needs permission to access your phone\'s storage
To share files between your phone and your desktop you need to give access to the phone\'s storage
To read and write SMS from your desktop you need to give permission to SMS
To see phone calls and SMS from the desktop you need to give permission to phone calls and SMS
To see a contact name instead of a phone number you need to give access to the phone\'s contacts
To share your contacts book with the desktop, you need to give contacts permission
Select a ringtone
Blocked numbers
Don\'t show calls and SMS from these numbers. Please specify one number per line
Cover art of current media
Device icon
Settings icon
Fullscreen
Exit presentation
You can lock your device and use the volume keys to go to the previous/next slide
Add a command
There are no commands registered
You can add new commands in the KDE Connect System Settings
You can add commands on the desktop
Media Player Control
Control your phones media players from another device
Dark theme
Other notifications
Persistent indicator
Media control
File transfer
Stop the current player
Copy URL to clipboard
Copied to clipboard
Device is not reachable
Device is not paired
There is no such device
This device does not have the Run Command Plugin enabled
Find remote device
Ring your remote device
Ring
System volume
Control the system volume of the remote device
Mute
All
Devices
Device name
Dark theme
More settings
Per-device settings can be found under \'Plugin settings\' from within a device.
Show persistent notification
Persistent notification
Tap to enable/disable in Notification settings
Extra options
Privacy options
Set your privacy options
New notification
Block contents of notifications
Block images in notifications
Notifications from other devices
+ Take picture
+ Take a picture and send it to another device
findmyphone_ringtone
diff --git a/res/xml/fileprovider_paths.xml b/res/xml/fileprovider_paths.xml
index a075ef96..67276880 100644
--- a/res/xml/fileprovider_paths.xml
+++ b/res/xml/fileprovider_paths.xml
@@ -1,6 +1,7 @@
+
diff --git a/src/org/kde/kdeconnect/Helpers/FilesHelper.java b/src/org/kde/kdeconnect/Helpers/FilesHelper.java
index 8b942b4a..54fcc284 100644
--- a/src/org/kde/kdeconnect/Helpers/FilesHelper.java
+++ b/src/org/kde/kdeconnect/Helpers/FilesHelper.java
@@ -1,103 +1,189 @@
/*
* Copyright 2014 Albert Vaca Cintora
*
* 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 .
*/
package org.kde.kdeconnect.Helpers;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.MediaStore;
import android.util.Log;
import android.webkit.MimeTypeMap;
+import org.kde.kdeconnect.NetworkPacket;
+
import java.io.File;
+import java.io.InputStream;
public class FilesHelper {
private static String getFileExt(String filename) {
//return MimeTypeMap.getFileExtensionFromUrl(filename);
return filename.substring((filename.lastIndexOf(".") + 1));
}
public static String getFileNameWithoutExt(String filename) {
int dot = filename.lastIndexOf(".");
return (dot < 0) ? filename : filename.substring(0, dot);
}
public static String getMimeTypeFromFile(String file) {
String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(getFileExt(file));
if (mime == null) mime = "*/*";
return mime;
}
public static String findNonExistingNameForNewFile(String path, String filename) {
int dot = filename.lastIndexOf(".");
String name = (dot < 0) ? filename : filename.substring(0, dot);
String ext = (dot < 0) ? "" : filename.substring(filename.lastIndexOf("."));
int num = 1;
while (new File(path + "/" + filename).exists()) {
filename = name + " (" + num + ")" + ext;
num++;
}
return filename;
}
//Following code from http://activemq.apache.org/maven/5.7.0/kahadb/apidocs/src-html/org/apache/kahadb/util/IOHelper.html
/**
* Converts any string into a string that is safe to use as a file name.
* The result will only include ascii characters and numbers, and the "-","_", and "." characters.
*/
private static String toFileSystemSafeName(String name, boolean dirSeparators, int maxFileLength) {
int size = name.length();
StringBuilder rc = new StringBuilder(size * 2);
for (int i = 0; i < size; i++) {
char c = name.charAt(i);
boolean valid = c >= 'a' && c <= 'z';
valid = valid || (c >= 'A' && c <= 'Z');
valid = valid || (c >= '0' && c <= '9');
valid = valid || (c == '_') || (c == '-') || (c == '.');
valid = valid || (dirSeparators && ((c == '/') || (c == '\\')));
if (valid) {
rc.append(c);
}
}
String result = rc.toString();
if (result.length() > maxFileLength) {
result = result.substring(result.length() - maxFileLength);
}
return result;
}
public static String toFileSystemSafeName(String name, boolean dirSeparators) {
return toFileSystemSafeName(name, dirSeparators, 255);
}
public static String toFileSystemSafeName(String name) {
return toFileSystemSafeName(name, true, 255);
}
private static int GetOpenFileCount() {
return new File("/proc/self/fd").listFiles().length;
}
public static void LogOpenFileCount() {
Log.e("KDE/FileCount", "" + GetOpenFileCount());
}
+
+
+ //Create the network package from the URI
+ public static NetworkPacket uriToNetworkPacket(final Context context, final Uri uri, String type) {
+
+ try {
+
+ ContentResolver cr = context.getContentResolver();
+ InputStream inputStream = cr.openInputStream(uri);
+
+ NetworkPacket np = new NetworkPacket(type);
+ long size = -1;
+
+ if (uri.getScheme().equals("file")) {
+ // file:// is a non media uri, so we cannot query the ContentProvider
+
+ np.set("filename", uri.getLastPathSegment());
+
+ try {
+ size = new File(uri.getPath()).length();
+ } catch (Exception e) {
+ Log.e("SendFileActivity", "Could not obtain file size");
+ e.printStackTrace();
+ }
+
+ } else {
+ // Probably a content:// uri, so we query the Media content provider
+
+ Cursor cursor = null;
+ try {
+ String[] proj = {MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.SIZE, MediaStore.MediaColumns.DISPLAY_NAME};
+ cursor = cr.query(uri, proj, null, null, null);
+ int column_index = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA);
+ cursor.moveToFirst();
+ String path = cursor.getString(column_index);
+ np.set("filename", Uri.parse(path).getLastPathSegment());
+ size = new File(path).length();
+ } catch (Exception unused) {
+
+ Log.w("SendFileActivity", "Could not resolve media to a file, trying to get info as media");
+
+ try {
+ int column_index = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME);
+ cursor.moveToFirst();
+ String name = cursor.getString(column_index);
+ np.set("filename", name);
+ } catch (Exception e) {
+ e.printStackTrace();
+ Log.e("SendFileActivity", "Could not obtain file name");
+ }
+
+ try {
+ int column_index = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE);
+ cursor.moveToFirst();
+ //For some reason this size can differ from the actual file size!
+ size = cursor.getInt(column_index);
+ } catch (Exception e) {
+ Log.e("SendFileActivity", "Could not obtain file size");
+ e.printStackTrace();
+ }
+ } finally {
+ try {
+ cursor.close();
+ } catch (Exception ignored) {
+ }
+ }
+
+ }
+
+ np.setPayload(new NetworkPacket.Payload(inputStream, size));
+
+ return np;
+ } catch (Exception e) {
+ Log.e("SendFileActivity", "Exception creating network packet", e);
+ e.printStackTrace();
+ return null;
+ }
+ }
}
diff --git a/src/org/kde/kdeconnect/Plugins/PhotoPlugin/PhotoActivity.java b/src/org/kde/kdeconnect/Plugins/PhotoPlugin/PhotoActivity.java
new file mode 100644
index 00000000..5cb252a1
--- /dev/null
+++ b/src/org/kde/kdeconnect/Plugins/PhotoPlugin/PhotoActivity.java
@@ -0,0 +1,72 @@
+package org.kde.kdeconnect.Plugins.PhotoPlugin;
+
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Environment;
+import android.provider.MediaStore;
+
+import org.kde.kdeconnect.BackgroundService;
+
+import java.io.File;
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.core.content.FileProvider;
+
+public class PhotoActivity extends AppCompatActivity {
+
+ private Uri photoURI;
+ private PhotoPlugin plugin;
+
+ @Override
+ protected void onStart() {
+ super.onStart();
+
+ BackgroundService.runWithPlugin(this, getIntent().getStringExtra("deviceId"), PhotoPlugin.class, plugin -> {
+ this.plugin = plugin;
+ });
+
+ Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
+ if (takePictureIntent.resolveActivity(getPackageManager()) != null) {
+ File photoFile = null;
+ try {
+ photoFile = createImageFile();
+ } catch (IOException ignored) {
+ }
+ // Continue only if the File was successfully created
+ if (photoFile != null) {
+ photoURI = FileProvider.getUriForFile(this,
+ "org.kde.kdeconnect_tp.fileprovider",
+ photoFile);
+ takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoURI);
+ startActivityForResult(takePictureIntent, 1);
+ }
+ }
+
+ }
+
+ private File createImageFile() throws IOException {
+ // Create an image file name
+ String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
+ String imageFileName = "JPEG_" + timeStamp + "_";
+ File storageDir = getExternalFilesDir(Environment.DIRECTORY_PICTURES);
+ return File.createTempFile(
+ imageFileName, /* prefix */
+ ".jpg", /* suffix */
+ storageDir /* directory */
+ );
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+
+ if (resultCode == -1) {
+ plugin.sendPhoto(photoURI);
+ }
+ finish();
+ }
+}
diff --git a/src/org/kde/kdeconnect/Plugins/PhotoPlugin/PhotoPlugin.java b/src/org/kde/kdeconnect/Plugins/PhotoPlugin/PhotoPlugin.java
new file mode 100644
index 00000000..2500da77
--- /dev/null
+++ b/src/org/kde/kdeconnect/Plugins/PhotoPlugin/PhotoPlugin.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2019 Nicolas Fella
+ *
+ * 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 .
+ */
+
+package org.kde.kdeconnect.Plugins.PhotoPlugin;
+
+import android.content.Intent;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+
+import org.kde.kdeconnect.Helpers.FilesHelper;
+import org.kde.kdeconnect.NetworkPacket;
+import org.kde.kdeconnect.Plugins.Plugin;
+import org.kde.kdeconnect_tp.R;
+
+import androidx.core.content.ContextCompat;
+
+
+public class PhotoPlugin extends Plugin {
+
+ private final static String PACKET_TYPE_PHOTO = "kdeconnect.photo";
+ private final static String PACKET_TYPE_PHOTO_REQUEST = "kdeconnect.photo.request";
+
+ @Override
+ public String getDisplayName() {
+ return context.getResources().getString(R.string.take_picture);
+ }
+
+ @Override
+ public String getDescription() {
+ return context.getResources().getString(R.string.plugin_photo_desc);
+ }
+
+ @Override
+ public boolean onPacketReceived(NetworkPacket np) {
+ Intent intent = new Intent(context, PhotoActivity.class);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ intent.putExtra("deviceId", device.getDeviceId());
+ context.startActivity(intent);
+ return true;
+ }
+
+ void sendPhoto(Uri uri) {
+ NetworkPacket np = FilesHelper.uriToNetworkPacket(context, uri, PACKET_TYPE_PHOTO);
+ if (np != null) {
+ device.sendPacket(np);
+ }
+ }
+
+ @Override
+ public boolean hasMainActivity() {
+ return false;
+ }
+
+ @Override
+ public boolean displayInContextMenu() {
+ return false;
+ }
+
+ @Override
+ public String[] getSupportedPacketTypes() {
+ return new String[]{PACKET_TYPE_PHOTO_REQUEST};
+ }
+
+ @Override
+ public String[] getOutgoingPacketTypes() {
+ return new String[]{PACKET_TYPE_PHOTO};
+ }
+
+ @Override
+ public Drawable getIcon() {
+ return ContextCompat.getDrawable(context, R.drawable.ic_camera);
+ }
+}
diff --git a/src/org/kde/kdeconnect/Plugins/PingPlugin/PingPlugin.java b/src/org/kde/kdeconnect/Plugins/PingPlugin/PingPlugin.java
index 266d30f5..61e61936 100644
--- a/src/org/kde/kdeconnect/Plugins/PingPlugin/PingPlugin.java
+++ b/src/org/kde/kdeconnect/Plugins/PingPlugin/PingPlugin.java
@@ -1,133 +1,133 @@
/*
* Copyright 2014 Albert Vaca Cintora
*
* 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 .
-*/
+ * along with this program. If not, see .
+ */
package org.kde.kdeconnect.Plugins.PingPlugin;
import android.app.Activity;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.util.Log;
import org.kde.kdeconnect.Helpers.NotificationHelper;
import org.kde.kdeconnect.NetworkPacket;
import org.kde.kdeconnect.Plugins.Plugin;
import org.kde.kdeconnect.Plugins.PluginFactory;
import org.kde.kdeconnect.UserInterface.MainActivity;
import org.kde.kdeconnect_tp.R;
import androidx.core.app.NotificationCompat;
@PluginFactory.LoadablePlugin
public class PingPlugin extends Plugin {
private final static String PACKET_TYPE_PING = "kdeconnect.ping";
@Override
public String getDisplayName() {
return context.getResources().getString(R.string.pref_plugin_ping);
}
@Override
public String getDescription() {
return context.getResources().getString(R.string.pref_plugin_ping_desc);
}
@Override
public boolean onPacketReceived(NetworkPacket np) {
if (!np.getType().equals(PACKET_TYPE_PING)) {
Log.e("PingPlugin", "Ping plugin should not receive packets other than pings!");
return false;
}
//Log.e("PingPacketReceiver", "was a ping!");
PendingIntent resultPendingIntent = PendingIntent.getActivity(
context,
0,
new Intent(context, MainActivity.class),
PendingIntent.FLAG_UPDATE_CURRENT
);
int id;
String message;
if (np.has("message")) {
message = np.getString("message");
id = (int) System.currentTimeMillis();
} else {
message = "Ping!";
id = 42; //A unique id to create only one notification
}
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
Notification noti = new NotificationCompat.Builder(context, NotificationHelper.Channels.DEFAULT)
.setContentTitle(device.getName())
.setContentText(message)
.setContentIntent(resultPendingIntent)
.setTicker(message)
.setSmallIcon(R.drawable.ic_notification)
.setAutoCancel(true)
.setDefaults(Notification.DEFAULT_ALL)
.setStyle(new NotificationCompat.BigTextStyle().bigText(message))
.build();
NotificationHelper.notifyCompat(notificationManager, id, noti);
return true;
}
@Override
public String getActionName() {
return context.getString(R.string.send_ping);
}
@Override
public void startMainActivity(Activity activity) {
if (device != null) {
device.sendPacket(new NetworkPacket(PACKET_TYPE_PING));
}
}
@Override
public boolean hasMainActivity() {
return true;
}
@Override
public boolean displayInContextMenu() {
return true;
}
@Override
public String[] getSupportedPacketTypes() {
return new String[]{PACKET_TYPE_PING};
}
@Override
public String[] getOutgoingPacketTypes() {
return new String[]{PACKET_TYPE_PING};
}
}
diff --git a/src/org/kde/kdeconnect/Plugins/PluginFactory.java b/src/org/kde/kdeconnect/Plugins/PluginFactory.java
index bfb0fd85..25c7d74b 100644
--- a/src/org/kde/kdeconnect/Plugins/PluginFactory.java
+++ b/src/org/kde/kdeconnect/Plugins/PluginFactory.java
@@ -1,182 +1,181 @@
/*
* Copyright 2014 Albert Vaca Cintora
*
* 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 .
*/
package org.kde.kdeconnect.Plugins;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.util.Log;
import org.atteo.classindex.ClassIndex;
import org.atteo.classindex.IndexAnnotated;
import org.kde.kdeconnect.Device;
-
import java.util.Collections;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
public class PluginFactory {
@IndexAnnotated
public @interface LoadablePlugin { } //Annotate plugins with this so PluginFactory finds them
public static class PluginInfo {
PluginInfo(String displayName, String description, Drawable icon,
boolean enabledByDefault, boolean hasSettings, boolean listenToUnpaired,
String[] supportedPacketTypes, String[] outgoingPacketTypes,
Class extends Plugin> instantiableClass) {
this.displayName = displayName;
this.description = description;
this.icon = icon;
this.enabledByDefault = enabledByDefault;
this.hasSettings = hasSettings;
this.listenToUnpaired = listenToUnpaired;
HashSet incoming = new HashSet<>();
if (supportedPacketTypes != null) Collections.addAll(incoming, supportedPacketTypes);
this.supportedPacketTypes = Collections.unmodifiableSet(incoming);
HashSet outgoing = new HashSet<>();
if (outgoingPacketTypes != null) Collections.addAll(outgoing, outgoingPacketTypes);
this.outgoingPacketTypes = Collections.unmodifiableSet(outgoing);
this.instantiableClass = instantiableClass;
}
public String getDisplayName() {
return displayName;
}
public String getDescription() {
return description;
}
public Drawable getIcon() {
return icon;
}
public boolean hasSettings() {
return hasSettings;
}
public boolean isEnabledByDefault() {
return enabledByDefault;
}
public boolean listenToUnpaired() {
return listenToUnpaired;
}
Set getOutgoingPacketTypes() {
return outgoingPacketTypes;
}
public Set getSupportedPacketTypes() {
return supportedPacketTypes;
}
Class extends Plugin> getInstantiableClass() {
return instantiableClass;
}
private final String displayName;
private final String description;
private final Drawable icon;
private final boolean enabledByDefault;
private final boolean hasSettings;
private final boolean listenToUnpaired;
private final Set supportedPacketTypes;
private final Set outgoingPacketTypes;
private final Class extends Plugin> instantiableClass;
}
private static final Map pluginInfo = new ConcurrentHashMap<>();
public static PluginInfo getPluginInfo(String pluginKey) {
return pluginInfo.get(pluginKey);
}
public static void initPluginInfo(Context context) {
try {
for (Class> pluginClass : ClassIndex.getAnnotated(LoadablePlugin.class)) {
Plugin p = ((Plugin) pluginClass.newInstance());
p.setContext(context, null);
PluginInfo info = new PluginInfo(p.getDisplayName(), p.getDescription(), p.getIcon(),
p.isEnabledByDefault(), p.hasSettings(), p.listensToUnpairedDevices(),
p.getSupportedPacketTypes(), p.getOutgoingPacketTypes(), p.getClass());
pluginInfo.put(p.getPluginKey(), info);
}
} catch (Exception e) {
throw new RuntimeException(e);
}
Log.i("PluginFactory","Loaded "+pluginInfo.size()+" plugins");
}
public static Set getAvailablePlugins() {
return pluginInfo.keySet();
}
public static Plugin instantiatePluginForDevice(Context context, String pluginKey, Device device) {
PluginInfo info = pluginInfo.get(pluginKey);
try {
Plugin plugin = info.getInstantiableClass().newInstance();
plugin.setContext(context, device);
return plugin;
} catch (Exception e) {
Log.e("PluginFactory", "Could not instantiate plugin: " + pluginKey);
e.printStackTrace();
return null;
}
}
public static Set getIncomingCapabilities() {
HashSet capabilities = new HashSet<>();
for (PluginInfo plugin : pluginInfo.values()) {
capabilities.addAll(plugin.getSupportedPacketTypes());
}
return capabilities;
}
public static Set getOutgoingCapabilities() {
HashSet capabilities = new HashSet<>();
for (PluginInfo plugin : pluginInfo.values()) {
capabilities.addAll(plugin.getOutgoingPacketTypes());
}
return capabilities;
}
public static Set pluginsForCapabilities(Set incoming, Set outgoing) {
HashSet plugins = new HashSet<>();
for (Map.Entry entry : pluginInfo.entrySet()) {
String pluginId = entry.getKey();
PluginInfo info = entry.getValue();
//Check incoming against outgoing
if (Collections.disjoint(outgoing, info.getSupportedPacketTypes())
&& Collections.disjoint(incoming, info.getOutgoingPacketTypes())) {
Log.i("PluginFactory", "Won't load " + pluginId + " because of unmatched capabilities");
continue; //No capabilities in common, do not load this plugin
}
plugins.add(pluginId);
}
return plugins;
}
}
diff --git a/src/org/kde/kdeconnect/Plugins/SharePlugin/SharePlugin.java b/src/org/kde/kdeconnect/Plugins/SharePlugin/SharePlugin.java
index 31286695..89528fb7 100644
--- a/src/org/kde/kdeconnect/Plugins/SharePlugin/SharePlugin.java
+++ b/src/org/kde/kdeconnect/Plugins/SharePlugin/SharePlugin.java
@@ -1,433 +1,357 @@
/*
* Copyright 2014 Albert Vaca Cintora
*
* 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 .
*/
package org.kde.kdeconnect.Plugins.SharePlugin;
import android.Manifest;
import android.app.Activity;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.ClipboardManager;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.res.Resources;
import android.database.Cursor;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.provider.MediaStore;
import android.util.Log;
import android.widget.Toast;
+import org.kde.kdeconnect.Helpers.FilesHelper;
import org.kde.kdeconnect.Helpers.NotificationHelper;
import org.kde.kdeconnect.NetworkPacket;
import org.kde.kdeconnect.Plugins.Plugin;
import org.kde.kdeconnect.Plugins.PluginFactory;
import org.kde.kdeconnect.UserInterface.PluginSettingsFragment;
import org.kde.kdeconnect_tp.R;
import java.io.File;
import java.io.InputStream;
import java.net.URL;
import java.util.ArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import androidx.annotation.WorkerThread;
import androidx.core.app.NotificationCompat;
import androidx.core.content.ContextCompat;
@PluginFactory.LoadablePlugin
public class SharePlugin extends Plugin {
private final static String PACKET_TYPE_SHARE_REQUEST = "kdeconnect.share.request";
private final static String PACKET_TYPE_SHARE_REQUEST_UPDATE = "kdeconnect.share.request.update";
final static String KEY_NUMBER_OF_FILES = "numberOfFiles";
final static String KEY_TOTAL_PAYLOAD_SIZE = "totalPayloadSize";
private final static boolean openUrlsDirectly = true;
private ExecutorService executorService;
private final Handler handler;
private CompositeReceiveFileRunnable receiveFileRunnable;
private final Callback receiveFileRunnableCallback;
public SharePlugin() {
executorService = Executors.newFixedThreadPool(5);
handler = new Handler(Looper.getMainLooper());
receiveFileRunnableCallback = new Callback();
}
@Override
public boolean onCreate() {
optionalPermissionExplanation = R.string.share_optional_permission_explanation;
return true;
}
@Override
public String getDisplayName() {
return context.getResources().getString(R.string.pref_plugin_sharereceiver);
}
@Override
public Drawable getIcon() {
return ContextCompat.getDrawable(context, R.drawable.share_plugin_action);
}
@Override
public String getDescription() {
return context.getResources().getString(R.string.pref_plugin_sharereceiver_desc);
}
@Override
public boolean hasMainActivity() {
return true;
}
@Override
public String getActionName() {
return context.getString(R.string.send_files);
}
@Override
public void startMainActivity(Activity parentActivity) {
Intent intent = new Intent(parentActivity, SendFileActivity.class);
intent.putExtra("deviceId", device.getDeviceId());
parentActivity.startActivity(intent);
}
@Override
public boolean hasSettings() {
return true;
}
@Override
@WorkerThread
public boolean onPacketReceived(NetworkPacket np) {
try {
if (np.getType().equals(PACKET_TYPE_SHARE_REQUEST_UPDATE)) {
if (receiveFileRunnable != null && receiveFileRunnable.isRunning()) {
receiveFileRunnable.updateTotals(np.getInt(KEY_NUMBER_OF_FILES), np.getLong(KEY_TOTAL_PAYLOAD_SIZE));
} else {
Log.d("SharePlugin", "Received update packet but CompositeUploadJob is null or not running");
}
return true;
}
if (np.has("filename")) {
if (isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
receiveFile(np);
} else {
Log.i("SharePlugin", "no Permission for Storage");
}
} else if (np.has("text")) {
Log.i("SharePlugin", "hasText");
receiveText(np);
} else if (np.has("url")) {
receiveUrl(np);
} else {
Log.e("SharePlugin", "Error: Nothing attached!");
}
} catch (Exception e) {
Log.e("SharePlugin", "Exception");
e.printStackTrace();
}
return true;
}
private void receiveUrl(NetworkPacket np) {
String url = np.getString("url");
Log.i("SharePlugin", "hasUrl: " + url);
Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
if (openUrlsDirectly) {
context.startActivity(browserIntent);
} else {
Resources res = context.getResources();
PendingIntent resultPendingIntent = PendingIntent.getActivity(
context,
0,
browserIntent,
PendingIntent.FLAG_UPDATE_CURRENT
);
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
Notification noti = new NotificationCompat.Builder(context, NotificationHelper.Channels.DEFAULT)
.setContentTitle(res.getString(R.string.received_url_title, device.getName()))
.setContentText(res.getString(R.string.received_url_text, url))
.setContentIntent(resultPendingIntent)
.setTicker(res.getString(R.string.received_url_title, device.getName()))
.setSmallIcon(R.drawable.ic_notification)
.setAutoCancel(true)
.setDefaults(Notification.DEFAULT_ALL)
.build();
NotificationHelper.notifyCompat(notificationManager, (int) System.currentTimeMillis(), noti);
}
}
private void receiveText(NetworkPacket np) {
String text = np.getString("text");
ClipboardManager cm = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
cm.setText(text);
handler.post(() -> Toast.makeText(context, R.string.shareplugin_text_saved, Toast.LENGTH_LONG).show());
}
@WorkerThread
private void receiveFile(NetworkPacket np) {
CompositeReceiveFileRunnable runnable;
boolean hasNumberOfFiles = np.has(KEY_NUMBER_OF_FILES);
boolean hasOpen = np.has("open");
if (hasNumberOfFiles && !hasOpen && receiveFileRunnable != null) {
runnable = receiveFileRunnable;
} else {
runnable = new CompositeReceiveFileRunnable(device, receiveFileRunnableCallback);
}
if (!hasNumberOfFiles) {
np.set(KEY_NUMBER_OF_FILES, 1);
np.set(KEY_TOTAL_PAYLOAD_SIZE, np.getPayloadSize());
}
runnable.addNetworkPacket(np);
if (runnable != receiveFileRunnable) {
if (hasNumberOfFiles && !hasOpen) {
receiveFileRunnable = runnable;
}
executorService.execute(runnable);
}
}
@Override
public PluginSettingsFragment getSettingsFragment(Activity activity) {
return ShareSettingsFragment.newInstance(getPluginKey());
}
void queuedSendUriList(final ArrayList uriList) {
//Read all the data early, as we only have permissions to do it while the activity is alive
final ArrayList toSend = new ArrayList<>();
for (Uri uri : uriList) {
- NetworkPacket np = uriToNetworkPacket(context, uri);
+ NetworkPacket np = FilesHelper.uriToNetworkPacket(context, uri, PACKET_TYPE_SHARE_REQUEST);
if (np != null) {
toSend.add(np);
}
}
//Callback that shows a progress notification
final NotificationUpdateCallback notificationUpdateCallback = new NotificationUpdateCallback(context, device, toSend);
//Do the sending in background
new Thread(() -> {
//Actually send the files
try {
for (NetworkPacket np : toSend) {
boolean success = device.sendPacketBlocking(np, notificationUpdateCallback);
if (!success) {
Log.e("SharePlugin", "Error sending files");
return;
}
}
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
- //Create the network package from the URI
- private static NetworkPacket uriToNetworkPacket(final Context context, final Uri uri) {
-
- try {
-
- ContentResolver cr = context.getContentResolver();
- InputStream inputStream = cr.openInputStream(uri);
-
- NetworkPacket np = new NetworkPacket(PACKET_TYPE_SHARE_REQUEST);
- long size = -1;
-
- if (uri.getScheme().equals("file")) {
- // file:// is a non media uri, so we cannot query the ContentProvider
-
- np.set("filename", uri.getLastPathSegment());
-
- try {
- size = new File(uri.getPath()).length();
- } catch (Exception e) {
- Log.e("SendFileActivity", "Could not obtain file size");
- e.printStackTrace();
- }
-
- } else {
- // Probably a content:// uri, so we query the Media content provider
-
- Cursor cursor = null;
- try {
- String[] proj = {MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.SIZE, MediaStore.MediaColumns.DISPLAY_NAME};
- cursor = cr.query(uri, proj, null, null, null);
- int column_index = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA);
- cursor.moveToFirst();
- String path = cursor.getString(column_index);
- np.set("filename", Uri.parse(path).getLastPathSegment());
- size = new File(path).length();
- } catch (Exception unused) {
-
- Log.w("SendFileActivity", "Could not resolve media to a file, trying to get info as media");
-
- try {
- int column_index = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME);
- cursor.moveToFirst();
- String name = cursor.getString(column_index);
- np.set("filename", name);
- } catch (Exception e) {
- e.printStackTrace();
- Log.e("SendFileActivity", "Could not obtain file name");
- }
-
- try {
- int column_index = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE);
- cursor.moveToFirst();
- //For some reason this size can differ from the actual file size!
- size = cursor.getInt(column_index);
- } catch (Exception e) {
- Log.e("SendFileActivity", "Could not obtain file size");
- e.printStackTrace();
- }
- } finally {
- try {
- cursor.close();
- } catch (Exception ignored) {
- }
- }
-
- }
-
- np.setPayload(new NetworkPacket.Payload(inputStream, size));
-
- return np;
- } catch (Exception e) {
- Log.e("SendFileActivity", "Exception sending files");
- e.printStackTrace();
- return null;
- }
- }
-
public void share(Intent intent) {
Bundle extras = intent.getExtras();
if (extras != null) {
if (extras.containsKey(Intent.EXTRA_STREAM)) {
try {
ArrayList uriList;
if (!Intent.ACTION_SEND.equals(intent.getAction())) {
uriList = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
} else {
Uri uri = extras.getParcelable(Intent.EXTRA_STREAM);
uriList = new ArrayList<>();
uriList.add(uri);
}
queuedSendUriList(uriList);
} catch (Exception e) {
Log.e("ShareActivity", "Exception");
e.printStackTrace();
}
} else if (extras.containsKey(Intent.EXTRA_TEXT)) {
String text = extras.getString(Intent.EXTRA_TEXT);
String subject = extras.getString(Intent.EXTRA_SUBJECT);
//Hack: Detect shared youtube videos, so we can open them in the browser instead of as text
if (subject != null && subject.endsWith("YouTube")) {
int index = text.indexOf(": http://youtu.be/");
if (index > 0) {
text = text.substring(index + 2); //Skip ": "
}
}
boolean isUrl;
try {
new URL(text);
isUrl = true;
} catch (Exception e) {
isUrl = false;
}
NetworkPacket np = new NetworkPacket(SharePlugin.PACKET_TYPE_SHARE_REQUEST);
if (isUrl) {
np.set("url", text);
} else {
np.set("text", text);
}
device.sendPacket(np);
}
}
}
@Override
public String[] getSupportedPacketTypes() {
return new String[]{PACKET_TYPE_SHARE_REQUEST, PACKET_TYPE_SHARE_REQUEST_UPDATE};
}
@Override
public String[] getOutgoingPacketTypes() {
return new String[]{PACKET_TYPE_SHARE_REQUEST};
}
@Override
public String[] getOptionalPermissions() {
return new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE};
}
private class Callback implements CompositeReceiveFileRunnable.CallBack {
@Override
public void onSuccess(CompositeReceiveFileRunnable runnable) {
if (runnable == receiveFileRunnable) {
receiveFileRunnable = null;
}
}
@Override
public void onError(CompositeReceiveFileRunnable runnable, Throwable error) {
Log.e("SharePlugin", "onError() - " + error.getMessage());
if (runnable == receiveFileRunnable) {
receiveFileRunnable = null;
}
}
}
}