diff --git a/res/values/strings.xml b/res/values/strings.xml index f746029a..1f3bd0cb 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -1,358 +1,364 @@ 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 To receive keypresses you need to activate the KDE Connect Remote Keyboard 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 + + Sending %1$d file to %2$s + Sending %1$d files to %2$s + - Sent %1$d file - Sent %1$d out of %2$d files + File: %1$s + (File %2$d of %3$d) : %1$s 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 + + Sent file to %1$s + Sent %2$d files to %1$s" + + + Failed sending file to %1$s + Failed sending %2$d of %3$d files to %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 SD card %d SD card (read only) Camera pictures Add device Hostname or IP address Detected SD cards Edit SD card Configured storage locations Add storage location Edit storage location Add camera folder shortcut Add a shortcut to the camera folder Do not add a shortcut to the camera folder key_sftp_preference_category key_sftp_add_storage key_sftp_add_camera_shotcut key_sftp_storage_info%d" key_sftp_storage_info_list Storage location This location has already been configured click to select Display name This display name is already used Display name cannot be empty Delete No SD card detected No storage locations configured To access files remotely you have to configure storage locations Add host/IP Hostname or IP 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 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 on the desktop you need to give permission to phone call logs and phone state 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 Launch camera Launch the camera app to ease taking and transferring pictures findmyphone_ringtone No suitable app found to open this file diff --git a/src/org/kde/kdeconnect/Backends/LanBackend/LanLink.java b/src/org/kde/kdeconnect/Backends/LanBackend/LanLink.java index b82434c0..0a5485ec 100644 --- a/src/org/kde/kdeconnect/Backends/LanBackend/LanLink.java +++ b/src/org/kde/kdeconnect/Backends/LanBackend/LanLink.java @@ -1,295 +1,297 @@ /* * 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.Backends.LanBackend; import android.content.Context; import android.util.Log; import org.json.JSONObject; import org.kde.kdeconnect.Backends.BaseLink; import org.kde.kdeconnect.Backends.BasePairingHandler; import org.kde.kdeconnect.Device; import org.kde.kdeconnect.Helpers.SecurityHelpers.RsaHelper; import org.kde.kdeconnect.Helpers.SecurityHelpers.SslHelper; import org.kde.kdeconnect.Helpers.StringsHelper; import org.kde.kdeconnect.NetworkPacket; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.net.InetSocketAddress; import java.net.ServerSocket; import java.net.Socket; import java.net.SocketTimeoutException; import java.nio.channels.NotYetConnectedException; import java.security.PublicKey; import javax.net.ssl.SSLSocket; public class LanLink extends BaseLink { public interface LinkDisconnectedCallback { void linkDisconnected(LanLink brokenLink); } public enum ConnectionStarted { Locally, Remotely } private ConnectionStarted connectionSource; // If the other device sent me a broadcast, // I should not close the connection with it // because it's probably trying to find me and // potentially ask for pairing. private volatile Socket socket = null; private final LinkDisconnectedCallback callback; @Override public void disconnect() { Log.i("LanLink/Disconnect","socket:"+ socket.hashCode()); try { socket.close(); } catch (IOException e) { Log.e("LanLink", "Error", e); } } //Returns the old socket public Socket reset(final Socket newSocket, ConnectionStarted connectionSource) throws IOException { Socket oldSocket = socket; socket = newSocket; this.connectionSource = connectionSource; if (oldSocket != null) { oldSocket.close(); //This should cancel the readThread } //Log.e("LanLink", "Start listening"); //Create a thread to take care of incoming data for the new socket new Thread(() -> { try { BufferedReader reader = new BufferedReader(new InputStreamReader(newSocket.getInputStream(), StringsHelper.UTF8)); while (true) { String packet; try { packet = reader.readLine(); } catch (SocketTimeoutException e) { continue; } if (packet == null) { throw new IOException("End of stream"); } if (packet.isEmpty()) { continue; } NetworkPacket np = NetworkPacket.unserialize(packet); receivedNetworkPacket(np); } } catch (Exception e) { Log.i("LanLink", "Socket closed: " + newSocket.hashCode() + ". Reason: " + e.getMessage()); try { Thread.sleep(300); } catch (InterruptedException ignored) {} // Wait a bit because we might receive a new socket meanwhile boolean thereIsaANewSocket = (newSocket != socket); if (!thereIsaANewSocket) { callback.linkDisconnected(LanLink.this); } } }).start(); return oldSocket; } public LanLink(Context context, String deviceId, LanLinkProvider linkProvider, Socket socket, ConnectionStarted connectionSource) throws IOException { super(context, deviceId, linkProvider); callback = linkProvider; reset(socket, connectionSource); } @Override public String getName() { return "LanLink"; } @Override public BasePairingHandler getPairingHandler(Device device, BasePairingHandler.PairingHandlerCallback callback) { return new LanPairingHandler(device, callback); } //Blocking, do not call from main thread private boolean sendPacketInternal(NetworkPacket np, final Device.SendPacketStatusCallback callback, PublicKey key) { if (socket == null) { Log.e("KDE/sendPacket", "Not yet connected"); callback.onFailure(new NotYetConnectedException()); return false; } try { //Prepare socket for the payload final ServerSocket server; if (np.hasPayload()) { server = LanLinkProvider.openServerSocketOnFreePort(LanLinkProvider.PAYLOAD_TRANSFER_MIN_PORT); JSONObject payloadTransferInfo = new JSONObject(); payloadTransferInfo.put("port", server.getLocalPort()); np.setPayloadTransferInfo(payloadTransferInfo); } else { server = null; } //Encrypt if key provided if (key != null) { np = RsaHelper.encrypt(np, key); } //Log.e("LanLink/sendPacket", np.getType()); //Send body of the network package try { OutputStream writer = socket.getOutputStream(); writer.write(np.serialize().getBytes(StringsHelper.UTF8)); writer.flush(); } catch (Exception e) { disconnect(); //main socket is broken, disconnect throw e; } //Send payload if (server != null) { Socket payloadSocket = null; OutputStream outputStream = null; InputStream inputStream; try { //Wait a maximum of 10 seconds for the other end to establish a connection with our socket, close it afterwards server.setSoTimeout(10*1000); payloadSocket = server.accept(); //Convert to SSL if needed if (socket instanceof SSLSocket) { payloadSocket = SslHelper.convertToSslSocket(context, payloadSocket, getDeviceId(), true, false); } outputStream = payloadSocket.getOutputStream(); inputStream = np.getPayload().getInputStream(); Log.i("KDE/LanLink", "Beginning to send payload"); byte[] buffer = new byte[4096]; int bytesRead; long size = np.getPayloadSize(); long progress = 0; long timeSinceLastUpdate = -1; - while ((bytesRead = inputStream.read(buffer)) != -1) { + while (!np.isCanceled() && (bytesRead = inputStream.read(buffer)) != -1) { //Log.e("ok",""+bytesRead); progress += bytesRead; outputStream.write(buffer, 0, bytesRead); if (size > 0) { if (timeSinceLastUpdate + 500 < System.currentTimeMillis()) { //Report progress every half a second long percent = ((100 * progress) / size); callback.onProgressChanged((int) percent); timeSinceLastUpdate = System.currentTimeMillis(); } } } outputStream.flush(); Log.i("KDE/LanLink", "Finished sending payload ("+progress+" bytes written)"); } finally { try { server.close(); } catch (Exception ignored) { } try { payloadSocket.close(); } catch (Exception ignored) { } np.getPayload().close(); try { outputStream.close(); } catch (Exception ignored) { } } } - callback.onSuccess(); + if (!np.isCanceled()) { + callback.onSuccess(); + } return true; } catch (Exception e) { if (callback != null) { callback.onFailure(e); } return false; } finally { //Make sure we close the payload stream, if any if (np.hasPayload()) { np.getPayload().close(); } } } //Blocking, do not call from main thread @Override public boolean sendPacket(NetworkPacket np, Device.SendPacketStatusCallback callback) { return sendPacketInternal(np, callback, null); } //Blocking, do not call from main thread @Override public boolean sendPacketEncrypted(NetworkPacket np, Device.SendPacketStatusCallback callback, PublicKey key) { return sendPacketInternal(np, callback, key); } private void receivedNetworkPacket(NetworkPacket np) { if (np.getType().equals(NetworkPacket.PACKET_TYPE_ENCRYPTED)) { try { np = RsaHelper.decrypt(np, privateKey); } catch(Exception e) { Log.e("KDE/onPacketReceived","Exception decrypting the package", e); } } if (np.hasPayloadTransferInfo()) { Socket payloadSocket = new Socket(); try { int tcpPort = np.getPayloadTransferInfo().getInt("port"); InetSocketAddress deviceAddress = (InetSocketAddress) socket.getRemoteSocketAddress(); payloadSocket.connect(new InetSocketAddress(deviceAddress.getAddress(), tcpPort)); // Use ssl if existing link is on ssl if (socket instanceof SSLSocket) { payloadSocket = SslHelper.convertToSslSocket(context, payloadSocket, getDeviceId(), true, true); } np.setPayload(new NetworkPacket.Payload(payloadSocket, np.getPayloadSize())); } catch (Exception e) { try { payloadSocket.close(); } catch(Exception ignored) { } Log.e("KDE/LanLink", "Exception connecting to payload remote socket", e); } } packageReceived(np); } @Override public boolean linkShouldBeKeptAlive() { return true; //FIXME: Current implementation is broken, so for now we will keep links always established //We keep the remotely initiated connections, since the remotes require them if they want to request //pairing to us, or connections that are already paired. //return (connectionSource == ConnectionStarted.Remotely); } } diff --git a/src/org/kde/kdeconnect/NetworkPacket.java b/src/org/kde/kdeconnect/NetworkPacket.java index fdb385f3..87ccdc07 100644 --- a/src/org/kde/kdeconnect/NetworkPacket.java +++ b/src/org/kde/kdeconnect/NetworkPacket.java @@ -1,364 +1,368 @@ /* * 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; import android.content.Context; import android.util.Log; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import org.kde.kdeconnect.Helpers.DeviceHelper; import org.kde.kdeconnect.Plugins.PluginFactory; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.net.Socket; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; public class NetworkPacket { public final static int ProtocolVersion = 7; public final static String PACKET_TYPE_IDENTITY = "kdeconnect.identity"; public final static String PACKET_TYPE_PAIR = "kdeconnect.pair"; public final static String PACKET_TYPE_ENCRYPTED = "kdeconnect.encrypted"; public static Set protocolPacketTypes = new HashSet() {{ add(PACKET_TYPE_IDENTITY); add(PACKET_TYPE_PAIR); add(PACKET_TYPE_ENCRYPTED); }}; private long mId; String mType; private JSONObject mBody; private Payload mPayload; private JSONObject mPayloadTransferInfo; + private volatile boolean canceled; private NetworkPacket() { } public NetworkPacket(String type) { mId = System.currentTimeMillis(); mType = type; mBody = new JSONObject(); mPayload = null; mPayloadTransferInfo = new JSONObject(); } + public boolean isCanceled() { return canceled; } + public void cancel() { canceled = true; } + public String getType() { return mType; } public long getId() { return mId; } //Most commons getters and setters defined for convenience public String getString(String key) { return mBody.optString(key, ""); } public String getString(String key, String defaultValue) { return mBody.optString(key, defaultValue); } public void set(String key, String value) { if (value == null) return; try { mBody.put(key, value); } catch (Exception ignored) { } } public int getInt(String key) { return mBody.optInt(key, -1); } public int getInt(String key, int defaultValue) { return mBody.optInt(key, defaultValue); } public long getLong(String key) { return mBody.optLong(key, -1); } public long getLong(String key, long defaultValue) { return mBody.optLong(key, defaultValue); } public void set(String key, int value) { try { mBody.put(key, value); } catch (Exception ignored) { } } public boolean getBoolean(String key) { return mBody.optBoolean(key, false); } public boolean getBoolean(String key, boolean defaultValue) { return mBody.optBoolean(key, defaultValue); } public void set(String key, boolean value) { try { mBody.put(key, value); } catch (Exception ignored) { } } public double getDouble(String key) { return mBody.optDouble(key, Double.NaN); } public double getDouble(String key, double defaultValue) { return mBody.optDouble(key, defaultValue); } public void set(String key, double value) { try { mBody.put(key, value); } catch (Exception ignored) { } } public JSONArray getJSONArray(String key) { return mBody.optJSONArray(key); } public void set(String key, JSONArray value) { try { mBody.put(key, value); } catch (Exception ignored) { } } public JSONObject getJSONObject(String key) { return mBody.optJSONObject(key); } public void set(String key, JSONObject value) { try { mBody.put(key, value); } catch (JSONException ignored) { } } private Set getStringSet(String key) { JSONArray jsonArray = mBody.optJSONArray(key); if (jsonArray == null) return null; Set list = new HashSet<>(); int length = jsonArray.length(); for (int i = 0; i < length; i++) { try { String str = jsonArray.getString(i); list.add(str); } catch (Exception ignored) { } } return list; } public Set getStringSet(String key, Set defaultValue) { if (mBody.has(key)) return getStringSet(key); else return defaultValue; } public void set(String key, Set value) { try { JSONArray jsonArray = new JSONArray(); for (String str : value) { jsonArray.put(str); } mBody.put(key, jsonArray); } catch (Exception ignored) { } } public List getStringList(String key) { JSONArray jsonArray = mBody.optJSONArray(key); if (jsonArray == null) return null; List list = new ArrayList<>(); int length = jsonArray.length(); for (int i = 0; i < length; i++) { try { String str = jsonArray.getString(i); list.add(str); } catch (Exception ignored) { } } return list; } public List getStringList(String key, List defaultValue) { if (mBody.has(key)) return getStringList(key); else return defaultValue; } public void set(String key, List value) { try { JSONArray jsonArray = new JSONArray(); for (String str : value) { jsonArray.put(str); } mBody.put(key, jsonArray); } catch (Exception ignored) { } } public boolean has(String key) { return mBody.has(key); } public String serialize() throws JSONException { JSONObject jo = new JSONObject(); jo.put("id", mId); jo.put("type", mType); jo.put("body", mBody); if (hasPayload()) { jo.put("payloadSize", mPayload.payloadSize); jo.put("payloadTransferInfo", mPayloadTransferInfo); } //QJSon does not escape slashes, but Java JSONObject does. Converting to QJson format. return jo.toString().replace("\\/", "/") + "\n"; } static public NetworkPacket unserialize(String s) throws JSONException { NetworkPacket np = new NetworkPacket(); JSONObject jo = new JSONObject(s); np.mId = jo.getLong("id"); np.mType = jo.getString("type"); np.mBody = jo.getJSONObject("body"); if (jo.has("payloadSize")) { np.mPayloadTransferInfo = jo.getJSONObject("payloadTransferInfo"); np.mPayload = new Payload(jo.getLong("payloadSize")); } else { np.mPayloadTransferInfo = new JSONObject(); np.mPayload = new Payload(0); } return np; } static public NetworkPacket createIdentityPacket(Context context) { NetworkPacket np = new NetworkPacket(NetworkPacket.PACKET_TYPE_IDENTITY); String deviceId = DeviceHelper.getDeviceId(context); try { np.mBody.put("deviceId", deviceId); np.mBody.put("deviceName", DeviceHelper.getDeviceName(context)); np.mBody.put("protocolVersion", NetworkPacket.ProtocolVersion); np.mBody.put("deviceType", DeviceHelper.getDeviceType(context).toString()); np.mBody.put("incomingCapabilities", new JSONArray(PluginFactory.getIncomingCapabilities())); np.mBody.put("outgoingCapabilities", new JSONArray(PluginFactory.getOutgoingCapabilities())); } catch (Exception e) { Log.e("NetworkPackage", "Exception on createIdentityPacket", e); } return np; } public void setPayload(Payload payload) { mPayload = payload; } public Payload getPayload() { return mPayload; } public long getPayloadSize() { return mPayload == null ? 0 : mPayload.payloadSize; } public boolean hasPayload() { return (mPayload != null && mPayload.payloadSize != 0); } public boolean hasPayloadTransferInfo() { return (mPayloadTransferInfo.length() > 0); } public JSONObject getPayloadTransferInfo() { return mPayloadTransferInfo; } public void setPayloadTransferInfo(JSONObject payloadTransferInfo) { mPayloadTransferInfo = payloadTransferInfo; } public static class Payload { private InputStream inputStream; private Socket inputSocket; private long payloadSize; - Payload(long payloadSize) { + public Payload(long payloadSize) { this((InputStream)null, payloadSize); } public Payload(byte[] data) { this(new ByteArrayInputStream(data), data.length); } /** * NOTE: Do not use this to set an SSLSockets InputStream as the payload, use Payload(Socket, long) instead because of this bug */ public Payload(InputStream inputStream, long payloadSize) { this.inputSocket = null; this.inputStream = inputStream; this.payloadSize = payloadSize; } public Payload(Socket inputSocket, long payloadSize) throws IOException { this.inputSocket = inputSocket; this.inputStream = inputSocket.getInputStream(); this.payloadSize = payloadSize; } /** * NOTE: Do not close the InputStream directly call Payload.close() instead, this is because of this bug */ public InputStream getInputStream() { return inputStream; } long getPayloadSize() { return payloadSize; } public void close() { //TODO: If socket only close socket if that also closes the streams that is try { if (inputStream != null) { inputStream.close(); } } catch(IOException ignored) {} try { if (inputSocket != null) { inputSocket.close(); } } catch (IOException ignored) {} } } } diff --git a/src/org/kde/kdeconnect/Plugins/SharePlugin/CompositeReceiveFileJob.java b/src/org/kde/kdeconnect/Plugins/SharePlugin/CompositeReceiveFileJob.java index 6347f8ba..d275f9ee 100644 --- a/src/org/kde/kdeconnect/Plugins/SharePlugin/CompositeReceiveFileJob.java +++ b/src/org/kde/kdeconnect/Plugins/SharePlugin/CompositeReceiveFileJob.java @@ -1,336 +1,337 @@ /* * Copyright 2018 Erik Duisters * * 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.app.DownloadManager; import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; import android.net.Uri; import android.os.Build; import android.util.Log; import org.kde.kdeconnect.Device; import org.kde.kdeconnect.Helpers.FilesHelper; import org.kde.kdeconnect.Helpers.MediaStoreHelper; import org.kde.kdeconnect.NetworkPacket; import org.kde.kdeconnect.async.BackgroundJob; import org.kde.kdeconnect_tp.R; import java.io.BufferedOutputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.util.ArrayList; import java.util.List; import androidx.core.content.FileProvider; import androidx.documentfile.provider.DocumentFile; public class CompositeReceiveFileJob extends BackgroundJob { - private final ShareNotification shareNotification; + private final ReceiveNotification receiveNotification; private NetworkPacket currentNetworkPacket; private String currentFileName; private int currentFileNum; private long totalReceived; private long lastProgressTimeMillis; private long prevProgressPercentage; private final Object lock; //Use to protect concurrent access to the variables below private final List networkPacketList; private int totalNumFiles; private long totalPayloadSize; private boolean isRunning; CompositeReceiveFileJob(Device device, BackgroundJob.Callback callBack) { super(device, callBack); lock = new Object(); networkPacketList = new ArrayList<>(); - shareNotification = new ShareNotification(device); - shareNotification.addCancelAction(getId()); + receiveNotification = new ReceiveNotification(device); + receiveNotification.addCancelAction(getId()); currentFileNum = 0; totalNumFiles = 0; totalPayloadSize = 0; totalReceived = 0; lastProgressTimeMillis = 0; prevProgressPercentage = 0; } private Device getDevice() { return requestInfo; } boolean isRunning() { return isRunning; } void updateTotals(int numberOfFiles, long totalPayloadSize) { synchronized (lock) { this.totalNumFiles = numberOfFiles; this.totalPayloadSize = totalPayloadSize; - shareNotification.setTitle(getDevice().getContext().getResources() + receiveNotification.setTitle(getDevice().getContext().getResources() .getQuantityString(R.plurals.incoming_file_title, totalNumFiles, totalNumFiles, getDevice().getName())); } } void addNetworkPacket(NetworkPacket networkPacket) { synchronized (lock) { if (!networkPacketList.contains(networkPacket)) { networkPacketList.add(networkPacket); totalNumFiles = networkPacket.getInt(SharePlugin.KEY_NUMBER_OF_FILES, 1); totalPayloadSize = networkPacket.getLong(SharePlugin.KEY_TOTAL_PAYLOAD_SIZE); - shareNotification.setTitle(getDevice().getContext().getResources() + receiveNotification.setTitle(getDevice().getContext().getResources() .getQuantityString(R.plurals.incoming_file_title, totalNumFiles, totalNumFiles, getDevice().getName())); } } } @Override public void run() { boolean done; OutputStream outputStream = null; synchronized (lock) { done = networkPacketList.isEmpty(); } try { DocumentFile fileDocument = null; isRunning = true; while (!done && !canceled) { synchronized (lock) { currentNetworkPacket = networkPacketList.get(0); } currentFileName = currentNetworkPacket.getString("filename", Long.toString(System.currentTimeMillis())); currentFileNum++; setProgress((int)prevProgressPercentage); fileDocument = getDocumentFileFor(currentFileName, currentNetworkPacket.getBoolean("open")); if (currentNetworkPacket.hasPayload()) { outputStream = new BufferedOutputStream(getDevice().getContext().getContentResolver().openOutputStream(fileDocument.getUri())); InputStream inputStream = currentNetworkPacket.getPayload().getInputStream(); long received = receiveFile(inputStream, outputStream); currentNetworkPacket.getPayload().close(); if ( received != currentNetworkPacket.getPayloadSize()) { fileDocument.delete(); if (!canceled) { throw new RuntimeException("Failed to receive: " + currentFileName + " received:" + received + " bytes, expected: " + currentNetworkPacket.getPayloadSize() + " bytes"); } } else { publishFile(fileDocument, received); } } else { + //TODO: Only set progress to 100 if this is the only file/packet to send setProgress(100); publishFile(fileDocument, 0); } boolean listIsEmpty; synchronized (lock) { networkPacketList.remove(0); listIsEmpty = networkPacketList.isEmpty(); } if (listIsEmpty && !canceled) { try { Thread.sleep(1000); } catch (InterruptedException ignored) {} synchronized (lock) { if (currentFileNum < totalNumFiles && networkPacketList.isEmpty()) { throw new RuntimeException("Failed to receive " + (totalNumFiles - currentFileNum + 1) + " files"); } } } synchronized (lock) { done = networkPacketList.isEmpty(); } } isRunning = false; if (canceled) { - shareNotification.cancel(); + receiveNotification.cancel(); return; } int numFiles; synchronized (lock) { numFiles = totalNumFiles; } if (numFiles == 1 && currentNetworkPacket.has("open")) { - shareNotification.cancel(); + receiveNotification.cancel(); openFile(fileDocument); } else { //Update the notification and allow to open the file from it - shareNotification.setFinished(getDevice().getContext().getResources().getQuantityString(R.plurals.received_files_title, numFiles, getDevice().getName(), numFiles)); + receiveNotification.setFinished(getDevice().getContext().getResources().getQuantityString(R.plurals.received_files_title, numFiles, getDevice().getName(), numFiles)); if (totalNumFiles == 1 && fileDocument != null) { - shareNotification.setURI(fileDocument.getUri(), fileDocument.getType(), fileDocument.getName()); + receiveNotification.setURI(fileDocument.getUri(), fileDocument.getType(), fileDocument.getName()); } - shareNotification.show(); + receiveNotification.show(); } reportResult(null); } catch (ActivityNotFoundException e) { - shareNotification.setFinished(getDevice().getContext().getString(R.string.no_app_for_opening)); - shareNotification.show(); + receiveNotification.setFinished(getDevice().getContext().getString(R.string.no_app_for_opening)); + receiveNotification.show(); } catch (Exception e) { isRunning = false; Log.e("Shareplugin", "Error receiving file", e); int failedFiles; synchronized (lock) { failedFiles = (totalNumFiles - currentFileNum + 1); } - shareNotification.setFinished(getDevice().getContext().getResources().getQuantityString(R.plurals.received_files_fail_title, failedFiles, getDevice().getName(), failedFiles, totalNumFiles)); - shareNotification.show(); + receiveNotification.setFinished(getDevice().getContext().getResources().getQuantityString(R.plurals.received_files_fail_title, failedFiles, getDevice().getName(), failedFiles, totalNumFiles)); + receiveNotification.show(); reportError(e); } finally { closeAllInputStreams(); networkPacketList.clear(); if (outputStream != null) { try { outputStream.close(); } catch (IOException ignored) {} } } } private DocumentFile getDocumentFileFor(final String filename, final boolean open) throws RuntimeException { final DocumentFile destinationFolderDocument; String filenameToUse = filename; //We need to check for already existing files only when storing in the default path. //User-defined paths use the new Storage Access Framework that already handles this. - //If the file should be opened immediately store it in the standard location to avoid the FileProvider trouble (See ShareNotification::setURI) + //If the file should be opened immediately store it in the standard location to avoid the FileProvider trouble (See ReceiveNotification::setURI) if (open || !ShareSettingsFragment.isCustomDestinationEnabled(getDevice().getContext())) { final String defaultPath = ShareSettingsFragment.getDefaultDestinationDirectory().getAbsolutePath(); filenameToUse = FilesHelper.findNonExistingNameForNewFile(defaultPath, filenameToUse); destinationFolderDocument = DocumentFile.fromFile(new File(defaultPath)); } else { destinationFolderDocument = ShareSettingsFragment.getDestinationDirectory(getDevice().getContext()); } String displayName = FilesHelper.getFileNameWithoutExt(filenameToUse); String mimeType = FilesHelper.getMimeTypeFromFile(filenameToUse); if ("*/*".equals(mimeType)) { displayName = filenameToUse; } DocumentFile fileDocument = destinationFolderDocument.createFile(mimeType, displayName); if (fileDocument == null) { throw new RuntimeException(getDevice().getContext().getString(R.string.cannot_create_file, filenameToUse)); } return fileDocument; } private long receiveFile(InputStream input, OutputStream output) throws IOException { byte data[] = new byte[4096]; int count; long received = 0; while ((count = input.read(data)) >= 0 && !canceled) { received += count; totalReceived += count; output.write(data, 0, count); long progressPercentage; synchronized (lock) { progressPercentage = (totalReceived * 100 / totalPayloadSize); } long curTimeMillis = System.currentTimeMillis(); if (progressPercentage != prevProgressPercentage && (progressPercentage == 100 || curTimeMillis - lastProgressTimeMillis >= 500)) { prevProgressPercentage = progressPercentage; lastProgressTimeMillis = curTimeMillis; setProgress((int)progressPercentage); } } output.flush(); return received; } private void closeAllInputStreams() { for (NetworkPacket np : networkPacketList) { np.getPayload().close(); } } private void setProgress(int progress) { synchronized (lock) { - shareNotification.setProgress(progress, getDevice().getContext().getResources() + receiveNotification.setProgress(progress, getDevice().getContext().getResources() .getQuantityString(R.plurals.incoming_files_text, totalNumFiles, currentFileName, currentFileNum, totalNumFiles)); } - shareNotification.show(); + receiveNotification.show(); } private void publishFile(DocumentFile fileDocument, long size) { if (!ShareSettingsFragment.isCustomDestinationEnabled(getDevice().getContext())) { Log.i("SharePlugin", "Adding to downloads"); DownloadManager manager = (DownloadManager) getDevice().getContext().getSystemService(Context.DOWNLOAD_SERVICE); manager.addCompletedDownload(fileDocument.getUri().getLastPathSegment(), getDevice().getName(), true, fileDocument.getType(), fileDocument.getUri().getPath(), size, false); } else { //Make sure it is added to the Android Gallery anyway Log.i("SharePlugin", "Adding to gallery"); MediaStoreHelper.indexFile(getDevice().getContext(), fileDocument.getUri()); } } private void openFile(DocumentFile fileDocument) { String mimeType = FilesHelper.getMimeTypeFromFile(fileDocument.getName()); Intent intent = new Intent(Intent.ACTION_VIEW); if (Build.VERSION.SDK_INT >= 24) { //Nougat and later require "content://" uris instead of "file://" uris File file = new File(fileDocument.getUri().getPath()); Uri contentUri = FileProvider.getUriForFile(getDevice().getContext(), "org.kde.kdeconnect_tp.fileprovider", file); intent.setDataAndType(contentUri, mimeType); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK); } else { intent.setDataAndType(fileDocument.getUri(), mimeType); } getDevice().getContext().startActivity(intent); } } diff --git a/src/org/kde/kdeconnect/Plugins/SharePlugin/CompositeUploadFileJob.java b/src/org/kde/kdeconnect/Plugins/SharePlugin/CompositeUploadFileJob.java new file mode 100644 index 00000000..a3d1b1e1 --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/SharePlugin/CompositeUploadFileJob.java @@ -0,0 +1,222 @@ +/* + * Copyright 2019 Erik Duisters + * + * 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.os.Handler; +import android.os.Looper; + +import org.kde.kdeconnect.Device; +import org.kde.kdeconnect.NetworkPacket; +import org.kde.kdeconnect.async.BackgroundJob; +import org.kde.kdeconnect_tp.R; + +import java.util.ArrayList; +import java.util.List; + +import androidx.annotation.NonNull; + +public class CompositeUploadFileJob extends BackgroundJob { + private boolean isRunning; + private Handler handler; + private String currentFileName; + private int currentFileNum; + private boolean updatePacketPending; + private long totalSend; + private int prevProgressPercentage; + private UploadNotification uploadNotification; + + private final Object lock; //Use to protect concurrent access to the variables below + private final List networkPacketList; + private NetworkPacket currentNetworkPacket; + private final Device.SendPacketStatusCallback sendPacketStatusCallback; + private int totalNumFiles; + private long totalPayloadSize; + + CompositeUploadFileJob(@NonNull Device device, @NonNull Callback callback) { + super(device, callback); + + isRunning = false; + handler = new Handler(Looper.getMainLooper()); + currentFileNum = 0; + currentFileName = ""; + updatePacketPending = false; + + lock = new Object(); + networkPacketList = new ArrayList<>(); + totalNumFiles = 0; + totalPayloadSize = 0; + totalSend = 0; + prevProgressPercentage = 0; + uploadNotification = new UploadNotification(getDevice()); + uploadNotification.addCancelAction(getId()); + + sendPacketStatusCallback = new SendPacketStatusCallback(); + } + + private Device getDevice() { return requestInfo; } + + @Override + public void run() { + boolean done; + + isRunning = true; + + synchronized (lock) { + done = networkPacketList.isEmpty(); + } + + try { + while (!done && !canceled) { + synchronized (lock) { + currentNetworkPacket = networkPacketList.remove(0); + } + + currentFileName = currentNetworkPacket.getString("filename"); + currentFileNum++; + + setProgress(prevProgressPercentage); + + addTotalsToNetworkPacket(currentNetworkPacket); + + if (!getDevice().sendPacketBlocking(currentNetworkPacket, sendPacketStatusCallback)) { + throw new RuntimeException("Sending packet failed"); + } + + synchronized (lock) { + done = networkPacketList.isEmpty(); + } + } + + if (canceled) { + uploadNotification.cancel(); + } else { + uploadNotification.setFinished(getDevice().getContext().getResources().getQuantityString(R.plurals.sent_files_title, currentFileNum, getDevice().getName(), currentFileNum)); + uploadNotification.show(); + + reportResult(null); + } + } catch (RuntimeException e) { + int failedFiles; + synchronized (lock) { + failedFiles = (totalNumFiles - currentFileNum + 1); + uploadNotification.setFinished(getDevice().getContext().getResources() + .getQuantityString(R.plurals.send_files_fail_title, failedFiles, getDevice().getName(), + failedFiles, totalNumFiles)); + } + + uploadNotification.show(); + reportError(e); + } finally { + isRunning = false; + + for (NetworkPacket networkPacket : networkPacketList) { + networkPacket.getPayload().close(); + } + networkPacketList.clear(); + } + } + + private void addTotalsToNetworkPacket(NetworkPacket networkPacket) { + synchronized (lock) { + networkPacket.set(SharePlugin.KEY_NUMBER_OF_FILES, totalNumFiles); + networkPacket.set(SharePlugin.KEY_TOTAL_PAYLOAD_SIZE, totalPayloadSize); + } + } + + private void setProgress(int progress) { + synchronized (lock) { + uploadNotification.setProgress(progress, getDevice().getContext().getResources() + .getQuantityString(R.plurals.outgoing_files_text, totalNumFiles, currentFileName, currentFileNum, totalNumFiles)); + } + uploadNotification.show(); + } + + void addNetworkPacket(@NonNull NetworkPacket networkPacket) { + synchronized (lock) { + networkPacketList.add(networkPacket); + + totalNumFiles++; + + if (networkPacket.getPayloadSize() >= 0) { + totalPayloadSize += networkPacket.getPayloadSize(); + } + + uploadNotification.setTitle(getDevice().getContext().getResources() + .getQuantityString(R.plurals.outgoing_file_title, totalNumFiles, totalNumFiles, getDevice().getName())); + + //Give SharePlugin some time to add more NetworkPackets + if (isRunning && !updatePacketPending) { + updatePacketPending = true; + handler.post(this::sendUpdatePacket); + } + } + } + + private void sendUpdatePacket() { + NetworkPacket np = new NetworkPacket(SharePlugin.PACKET_TYPE_SHARE_REQUEST_UPDATE); + + synchronized (lock) { + np.set("numberOfFiles", totalNumFiles); + np.set("totalPayloadSize", totalPayloadSize); + updatePacketPending = false; + } + + getDevice().sendPacket(np); + } + + @Override + public void cancel() { + super.cancel(); + + currentNetworkPacket.cancel(); + } + + private class SendPacketStatusCallback extends Device.SendPacketStatusCallback { + @Override + public void onProgressChanged(int percent) { + float send = totalSend + (currentNetworkPacket.getPayloadSize() * ((float)percent / 100)); + int progress = (int)((send * 100) / totalPayloadSize); + + if (progress != prevProgressPercentage) { + setProgress(progress); + prevProgressPercentage = progress; + } + } + + @Override + public void onSuccess() { + if (currentNetworkPacket.getPayloadSize() == 0) { + synchronized (lock) { + if (networkPacketList.isEmpty()) { + setProgress(100); + } + } + } + + totalSend += currentNetworkPacket.getPayloadSize(); + } + + @Override + public void onFailure(Throwable e) { + //Ignored + } + } +} diff --git a/src/org/kde/kdeconnect/Plugins/SharePlugin/NotificationUpdateCallback.java b/src/org/kde/kdeconnect/Plugins/SharePlugin/NotificationUpdateCallback.java deleted file mode 100644 index e742b0dd..00000000 --- a/src/org/kde/kdeconnect/Plugins/SharePlugin/NotificationUpdateCallback.java +++ /dev/null @@ -1,118 +0,0 @@ -package org.kde.kdeconnect.Plugins.SharePlugin; - -import android.app.NotificationManager; -import android.content.Context; -import android.content.res.Resources; -import android.util.Log; - -import org.kde.kdeconnect.Device; -import org.kde.kdeconnect.Helpers.NotificationHelper; -import org.kde.kdeconnect.NetworkPacket; -import org.kde.kdeconnect_tp.R; - -import java.util.ArrayList; - -import androidx.core.app.NotificationCompat; - -class NotificationUpdateCallback extends Device.SendPacketStatusCallback { - - private final Resources res; - private final Device device; - private final NotificationManager notificationManager; - private final NotificationCompat.Builder builder; - - private final ArrayList toSend; - - private final int notificationId; - - private int sentFiles = 0; - private final int numFiles; - - NotificationUpdateCallback(Context context, Device device, ArrayList toSend) { - this.toSend = toSend; - this.device = device; - this.res = context.getResources(); - - String title; - if (toSend.size() > 1) { - title = res.getString(R.string.outgoing_files_title, device.getName()); - } else { - title = res.getString(R.string.outgoing_file_title, device.getName()); - } - - notificationManager = (NotificationManager)context.getSystemService(Context.NOTIFICATION_SERVICE); - builder = new NotificationCompat.Builder(context, NotificationHelper.Channels.FILETRANSFER) - .setSmallIcon(android.R.drawable.stat_sys_upload) - .setAutoCancel(true) - .setOngoing(true) - .setProgress(100, 0, false) - .setContentTitle(title) - .setTicker(title); - - notificationId = (int) System.currentTimeMillis(); - - numFiles = toSend.size(); - - } - - @Override - public void onProgressChanged(int progress) { - builder.setProgress(100 * numFiles, (100 * sentFiles) + progress, false); - NotificationHelper.notifyCompat(notificationManager, notificationId, builder.build()); - } - - @Override - public void onSuccess() { - sentFiles++; - if (sentFiles == numFiles) { - updateDone(true); - } else { - updateText(); - } - NotificationHelper.notifyCompat(notificationManager, notificationId, builder.build()); - } - - @Override - public void onFailure(Throwable e) { - updateDone(false); - NotificationHelper.notifyCompat(notificationManager, notificationId, builder.build()); - Log.e("KDEConnect", "Exception", e); - } - - private void updateText() { - String text; - text = res.getQuantityString(R.plurals.outgoing_files_text, numFiles, sentFiles, numFiles); - builder.setContentText(text); - } - - private void updateDone(boolean successful) { - int icon; - String title; - String text; - - if (successful) { - if (numFiles > 1) { - text = res.getQuantityString(R.plurals.outgoing_files_text, numFiles, sentFiles, numFiles); - } else { - final String filename = toSend.get(0).getString("filename"); - text = res.getString(R.string.sent_file_text, filename); - } - title = res.getString(R.string.sent_file_title, device.getName()); - icon = android.R.drawable.stat_sys_upload_done; - } else { - final String filename = toSend.get(sentFiles).getString("filename"); - title = res.getString(R.string.sent_file_failed_title, device.getName()); - text = res.getString(R.string.sent_file_failed_text, filename); - icon = android.R.drawable.stat_notify_error; - } - - builder.setOngoing(false) - .setTicker(title) - .setContentTitle(title) - .setContentText(text) - .setSmallIcon(icon) - .setProgress(0, 0, false); //setting progress to 0 out of 0 remove the progress bar - } - -} - diff --git a/src/org/kde/kdeconnect/Plugins/SharePlugin/ShareNotification.java b/src/org/kde/kdeconnect/Plugins/SharePlugin/ReceiveNotification.java similarity index 99% rename from src/org/kde/kdeconnect/Plugins/SharePlugin/ShareNotification.java rename to src/org/kde/kdeconnect/Plugins/SharePlugin/ReceiveNotification.java index 5870ccd4..848ec315 100644 --- a/src/org/kde/kdeconnect/Plugins/SharePlugin/ShareNotification.java +++ b/src/org/kde/kdeconnect/Plugins/SharePlugin/ReceiveNotification.java @@ -1,211 +1,211 @@ package org.kde.kdeconnect.Plugins.SharePlugin; /* * Copyright 2017 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 . */ import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.net.Uri; import android.os.Build; import android.preference.PreferenceManager; import org.kde.kdeconnect.Device; import org.kde.kdeconnect.Helpers.NotificationHelper; import org.kde.kdeconnect_tp.R; import java.io.File; import java.io.IOException; import java.io.InputStream; import androidx.core.app.NotificationCompat; import androidx.core.content.FileProvider; -class ShareNotification { +class ReceiveNotification { private final NotificationManager notificationManager; private final int notificationId; private NotificationCompat.Builder builder; private final Device device; private long currentJobId; //https://documentation.onesignal.com/docs/android-customizations#section-big-picture private static final int bigImageWidth = 1440; private static final int bigImageHeight = 720; - public ShareNotification(Device device) { + public ReceiveNotification(Device device) { this.device = device; notificationId = (int) System.currentTimeMillis(); notificationManager = (NotificationManager) device.getContext().getSystemService(Context.NOTIFICATION_SERVICE); builder = new NotificationCompat.Builder(device.getContext(), NotificationHelper.Channels.FILETRANSFER) .setSmallIcon(android.R.drawable.stat_sys_download) .setAutoCancel(true) .setOngoing(true) .setProgress(100, 0, true); } public void show() { NotificationHelper.notifyCompat(notificationManager, notificationId, builder.build()); } public void cancel() { notificationManager.cancel(notificationId); } public void addCancelAction(long jobId) { builder.mActions.clear(); currentJobId = jobId; Intent cancelIntent = new Intent(device.getContext(), ShareBroadcastReceiver.class); cancelIntent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND); cancelIntent.setAction(SharePlugin.ACTION_CANCEL_SHARE); cancelIntent.putExtra(SharePlugin.CANCEL_SHARE_BACKGROUND_JOB_ID_EXTRA, jobId); cancelIntent.putExtra(SharePlugin.CANCEL_SHARE_DEVICE_ID_EXTRA, device.getDeviceId()); PendingIntent cancelPendingIntent = PendingIntent.getBroadcast(device.getContext(), 0, cancelIntent, PendingIntent.FLAG_UPDATE_CURRENT); builder.addAction(R.drawable.ic_reject_pairing, device.getContext().getString(R.string.cancel), cancelPendingIntent); } public long getCurrentJobId() { return currentJobId; } public int getNotificationId() { return notificationId; } public void setTitle(String title) { builder.setContentTitle(title); builder.setTicker(title); } public void setProgress(int progress, String progressMessage) { builder.setProgress( 100, progress, false); builder.setContentText(progressMessage); builder.setStyle(new NotificationCompat.BigTextStyle().bigText(progressMessage)); } public void setFinished(String message) { builder = new NotificationCompat.Builder(device.getContext(), NotificationHelper.Channels.DEFAULT); builder.setContentTitle(message) .setTicker(message) .setSmallIcon(android.R.drawable.stat_sys_download_done) .setAutoCancel(true) .setOngoing(false); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(device.getContext()); if (prefs.getBoolean("share_notification_preference", true)) { builder.setDefaults(Notification.DEFAULT_ALL); } } public void setURI(Uri destinationUri, String mimeType, String filename) { /* * We only support file URIs (because sending a content uri to another app does not work for security reasons). * In effect, that means only the default download folder currently works. * * TODO: implement our own content provider (instead of support-v4's FileProvider). It should: * - Proxy to real files (in case of the default download folder) * - Proxy to the underlying content uri (in case of a custom download folder) */ //If it's an image, try to show it in the notification if (mimeType.startsWith("image/")) { //https://developer.android.com/topic/performance/graphics/load-bitmap final BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; try (InputStream decodeBoundsInputStream = device.getContext().getContentResolver().openInputStream(destinationUri); InputStream decodeInputStream = device.getContext().getContentResolver().openInputStream(destinationUri)) { BitmapFactory.decodeStream(decodeBoundsInputStream, null, options); options.inJustDecodeBounds = false; options.inSampleSize = calculateInSampleSize(options, bigImageWidth, bigImageHeight); Bitmap image = BitmapFactory.decodeStream(decodeInputStream, null, options); if (image != null) { builder.setLargeIcon(image); builder.setStyle(new NotificationCompat.BigPictureStyle() .bigPicture(image)); } } catch (IOException ignored) { } } if (!"file".equals(destinationUri.getScheme())) { return; } Intent intent = new Intent(Intent.ACTION_VIEW); Intent shareIntent = new Intent(Intent.ACTION_SEND); shareIntent.setType(mimeType); if (Build.VERSION.SDK_INT >= 24) { //Nougat and later require "content://" uris instead of "file://" uris File file = new File(destinationUri.getPath()); Uri contentUri = FileProvider.getUriForFile(device.getContext(), "org.kde.kdeconnect_tp.fileprovider", file); intent.setDataAndType(contentUri, mimeType); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); shareIntent.putExtra(Intent.EXTRA_STREAM, contentUri); } else { intent.setDataAndType(destinationUri, mimeType); shareIntent.putExtra(Intent.EXTRA_STREAM, destinationUri); } intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); shareIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); PendingIntent resultPendingIntent = PendingIntent.getActivity( device.getContext(), 0, intent, PendingIntent.FLAG_UPDATE_CURRENT ); builder.setContentText(device.getContext().getResources().getString(R.string.received_file_text, filename)) .setContentIntent(resultPendingIntent); shareIntent = Intent.createChooser(shareIntent, device.getContext().getString(R.string.share_received_file, destinationUri.getLastPathSegment())); PendingIntent sharePendingIntent = PendingIntent.getActivity(device.getContext(), (int) System.currentTimeMillis(), shareIntent, PendingIntent.FLAG_UPDATE_CURRENT); NotificationCompat.Action.Builder shareAction = new NotificationCompat.Action.Builder( R.drawable.ic_share_white, device.getContext().getString(R.string.share), sharePendingIntent); builder.addAction(shareAction.build()); } private int calculateInSampleSize(BitmapFactory.Options options, int targetWidth, int targetHeight) { int inSampleSize = 1; if (options.outHeight > targetHeight || options.outWidth > targetWidth) { final int halfHeight = options.outHeight / 2; final int halfWidth = options.outWidth / 2; while ((halfHeight / inSampleSize) >= targetHeight && (halfWidth / inSampleSize) >= targetWidth) { inSampleSize *= 2; } } return inSampleSize; } } diff --git a/src/org/kde/kdeconnect/Plugins/SharePlugin/SendFileActivity.java b/src/org/kde/kdeconnect/Plugins/SharePlugin/SendFileActivity.java index 9dfbe308..dac88f1b 100644 --- a/src/org/kde/kdeconnect/Plugins/SharePlugin/SendFileActivity.java +++ b/src/org/kde/kdeconnect/Plugins/SharePlugin/SendFileActivity.java @@ -1,102 +1,102 @@ /* * 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.app.Activity; import android.content.ClipData; import android.content.Intent; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.util.Log; import android.widget.Toast; import org.kde.kdeconnect.BackgroundService; import org.kde.kdeconnect.UserInterface.ThemeUtil; import org.kde.kdeconnect_tp.R; import java.util.ArrayList; import androidx.appcompat.app.AppCompatActivity; public class SendFileActivity extends AppCompatActivity { private String mDeviceId; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); ThemeUtil.setUserPreferredTheme(this); mDeviceId = getIntent().getStringExtra("deviceId"); Intent intent = new Intent(Intent.ACTION_GET_CONTENT); intent.setType("*/*"); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); } intent.addCategory(Intent.CATEGORY_OPENABLE); try { startActivityForResult( Intent.createChooser(intent, getString(R.string.send_files)), Activity.RESULT_FIRST_USER); } catch (android.content.ActivityNotFoundException ex) { Toast.makeText(this, R.string.no_file_browser, Toast.LENGTH_SHORT).show(); finish(); } } @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { switch (requestCode) { case Activity.RESULT_FIRST_USER: if (resultCode == RESULT_OK) { final ArrayList uris = new ArrayList<>(); Uri uri = data.getData(); if (uri != null) { uris.add(uri); } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { ClipData clipdata = data.getClipData(); if (clipdata != null) { for (int i = 0; i < clipdata.getItemCount(); i++) { uris.add(clipdata.getItemAt(i).getUri()); } } } if (uris.isEmpty()) { Log.w("SendFileActivity", "No files to send?"); } else { - BackgroundService.RunWithPlugin(this, mDeviceId, SharePlugin.class, plugin -> plugin.queuedSendUriList(uris)); + BackgroundService.RunWithPlugin(this, mDeviceId, SharePlugin.class, plugin -> plugin.sendUriList(uris)); } } finish(); break; default: super.onActivityResult(requestCode, resultCode, data); } } } diff --git a/src/org/kde/kdeconnect/Plugins/SharePlugin/SharePlugin.java b/src/org/kde/kdeconnect/Plugins/SharePlugin/SharePlugin.java index b99908e1..dc18dac8 100644 --- a/src/org/kde/kdeconnect/Plugins/SharePlugin/SharePlugin.java +++ b/src/org/kde/kdeconnect/Plugins/SharePlugin/SharePlugin.java @@ -1,334 +1,335 @@ /* * 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.content.ClipboardManager; import android.content.Context; import android.content.Intent; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.util.Log; import android.widget.Toast; import org.kde.kdeconnect.Helpers.FilesHelper; 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.async.BackgroundJob; import org.kde.kdeconnect.async.BackgroundJobHandler; import org.kde.kdeconnect_tp.R; import java.net.URL; import java.util.ArrayList; import androidx.annotation.NonNull; import androidx.annotation.WorkerThread; import androidx.core.content.ContextCompat; @PluginFactory.LoadablePlugin public class SharePlugin extends Plugin { final static String ACTION_CANCEL_SHARE = "org.kde.kdeconnect.Plugins.SharePlugin.CancelShare"; final static String CANCEL_SHARE_DEVICE_ID_EXTRA = "deviceId"; final static String CANCEL_SHARE_BACKGROUND_JOB_ID_EXTRA = "backgroundJobId"; 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 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 BackgroundJobHandler backgroundJobHandler; private final Handler handler; private CompositeReceiveFileJob receiveFileJob; + private CompositeUploadFileJob uploadFileJob; private final Callback receiveFileJobCallback; public SharePlugin() { backgroundJobHandler = BackgroundJobHandler.newFixedThreadPoolBackgroundJobHander(5); handler = new Handler(Looper.getMainLooper()); receiveFileJobCallback = 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 (receiveFileJob != null && receiveFileJob.isRunning()) { receiveFileJob.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); + 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); context.startActivity(browserIntent); } 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) { CompositeReceiveFileJob job; boolean hasNumberOfFiles = np.has(KEY_NUMBER_OF_FILES); boolean hasOpen = np.has("open"); if (hasNumberOfFiles && !hasOpen && receiveFileJob != null) { job = receiveFileJob; } else { job = new CompositeReceiveFileJob(device, receiveFileJobCallback); } if (!hasNumberOfFiles) { np.set(KEY_NUMBER_OF_FILES, 1); np.set(KEY_TOTAL_PAYLOAD_SIZE, np.getPayloadSize()); } job.addNetworkPacket(np); if (job != receiveFileJob) { if (hasNumberOfFiles && !hasOpen) { receiveFileJob = job; } backgroundJobHandler.runJob(job); } } @Override public PluginSettingsFragment getSettingsFragment(Activity activity) { return ShareSettingsFragment.newInstance(getPluginKey()); } - void queuedSendUriList(final ArrayList uriList) { + void sendUriList(final ArrayList uriList) { + CompositeUploadFileJob job = null; + + if (uploadFileJob == null) { + job = new CompositeUploadFileJob(device, this.receiveFileJobCallback); + } else { + job = uploadFileJob; + } + //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 = FilesHelper.uriToNetworkPacket(context, uri, PACKET_TYPE_SHARE_REQUEST); if (np != null) { - toSend.add(np); + job.addNetworkPacket(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) { - Log.e("SharePlugin", "Error sending files", e); - } - }).start(); - + if (job != uploadFileJob) { + uploadFileJob = job; + backgroundJobHandler.runJob(uploadFileJob); + } } 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); + sendUriList(uriList); } catch (Exception e) { - Log.e("ShareActivity", "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 CompositeReceiveFileJob.Callback { + private class Callback implements BackgroundJob.Callback { @Override public void onResult(@NonNull BackgroundJob job, Void result) { if (job == receiveFileJob) { receiveFileJob = null; + } else if (job == uploadFileJob) { + uploadFileJob = null; } } @Override public void onError(@NonNull BackgroundJob job, @NonNull Throwable error) { if (job == receiveFileJob) { receiveFileJob = null; + } else if (job == uploadFileJob) { + uploadFileJob = null; } } } void cancelJob(long jobId) { if (backgroundJobHandler.isRunning(jobId)) { BackgroundJob job = backgroundJobHandler.getJob(jobId); if (job != null) { job.cancel(); if (job == receiveFileJob) { receiveFileJob = null; + } else if (job == uploadFileJob) { + uploadFileJob = null; } } } } } diff --git a/src/org/kde/kdeconnect/Plugins/SharePlugin/UploadNotification.java b/src/org/kde/kdeconnect/Plugins/SharePlugin/UploadNotification.java new file mode 100644 index 00000000..dbc47592 --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/SharePlugin/UploadNotification.java @@ -0,0 +1,108 @@ +/* + * Copyright 2019 Erik Duisters + * + * 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.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; + +import org.kde.kdeconnect.Device; +import org.kde.kdeconnect.Helpers.NotificationHelper; +import org.kde.kdeconnect_tp.R; + +import androidx.core.app.NotificationCompat; +import androidx.preference.PreferenceManager; + +class UploadNotification { + private final NotificationManager notificationManager; + private NotificationCompat.Builder builder; + private final int notificationId; + private final Device device; + private long currentJobId; + + UploadNotification(Device device) { + this.device = device; + + notificationId = (int) System.currentTimeMillis(); + notificationManager = (NotificationManager) device.getContext().getSystemService(Context.NOTIFICATION_SERVICE); + builder = new NotificationCompat.Builder(device.getContext(), NotificationHelper.Channels.FILETRANSFER) + .setSmallIcon(android.R.drawable.stat_sys_upload) + .setAutoCancel(true) + .setOngoing(true) + .setProgress(100, 0, true); + } + + void addCancelAction(long jobId) { + builder.mActions.clear(); + + currentJobId = jobId; + Intent cancelIntent = new Intent(device.getContext(), ShareBroadcastReceiver.class); + cancelIntent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND); + cancelIntent.setAction(SharePlugin.ACTION_CANCEL_SHARE); + cancelIntent.putExtra(SharePlugin.CANCEL_SHARE_BACKGROUND_JOB_ID_EXTRA, jobId); + cancelIntent.putExtra(SharePlugin.CANCEL_SHARE_DEVICE_ID_EXTRA, device.getDeviceId()); + PendingIntent cancelPendingIntent = PendingIntent.getBroadcast(device.getContext(), 0, cancelIntent, PendingIntent.FLAG_UPDATE_CURRENT); + + builder.addAction(R.drawable.ic_reject_pairing, device.getContext().getString(R.string.cancel), cancelPendingIntent); + } + + public void setTitle(String title) { + builder.setContentTitle(title); + builder.setTicker(title); + } + + public void setProgress(int progress, String progressMessage) { + builder.setProgress( 100, progress, false); + builder.setContentText(progressMessage); + builder.setStyle(new NotificationCompat.BigTextStyle().bigText(progressMessage)); + } + + public void setFinished(String message) { + builder = new NotificationCompat.Builder(device.getContext(), NotificationHelper.Channels.DEFAULT); + builder.setContentTitle(message) + .setTicker(message) + .setSmallIcon(android.R.drawable.stat_sys_upload_done) + .setAutoCancel(true) + .setOngoing(false); + + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(device.getContext()); + if (prefs.getBoolean("share_notification_preference", true)) { + builder.setDefaults(Notification.DEFAULT_ALL); + } + } + + public void setFailed(String message) { + setFinished(message); + builder.setSmallIcon(android.R.drawable.stat_notify_error); + } + + public void cancel() { + notificationManager.cancel(notificationId); + } + + void show() { + NotificationHelper.notifyCompat(notificationManager, notificationId, builder.build()); + } +} +