diff --git a/src/org/kde/kdeconnect/Backends/BluetoothBackend/BluetoothLink.java b/src/org/kde/kdeconnect/Backends/BluetoothBackend/BluetoothLink.java index b37eaf9e..8004a17b 100644 --- a/src/org/kde/kdeconnect/Backends/BluetoothBackend/BluetoothLink.java +++ b/src/org/kde/kdeconnect/Backends/BluetoothBackend/BluetoothLink.java @@ -1,257 +1,256 @@ /* * Copyright 2016 Saikrishna Arcot * * 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.BluetoothBackend; -import android.annotation.TargetApi; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothServerSocket; import android.bluetooth.BluetoothSocket; import android.content.Context; import android.os.Build; import android.util.Log; import org.json.JSONException; 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.NetworkPacket; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.io.Reader; import java.nio.charset.Charset; import java.security.PublicKey; import java.util.UUID; public class BluetoothLink extends BaseLink { private final BluetoothSocket socket; private final BluetoothLinkProvider linkProvider; private boolean continueAccepting = true; private final Thread receivingThread = new Thread(new Runnable() { @Override public void run() { StringBuilder sb = new StringBuilder(); try { Reader reader = new InputStreamReader(socket.getInputStream(), "UTF-8"); char[] buf = new char[512]; while (continueAccepting) { while (sb.indexOf("\n") == -1 && continueAccepting) { int charsRead; if ((charsRead = reader.read(buf)) > 0) { sb.append(buf, 0, charsRead); } } int endIndex = sb.indexOf("\n"); if (endIndex != -1) { String message = sb.substring(0, endIndex + 1); sb.delete(0, endIndex + 1); processMessage(message); } } } catch (IOException e) { Log.e("BluetoothLink/receiving", "Connection to " + socket.getRemoteDevice().getAddress() + " likely broken.", e); disconnect(); } } private void processMessage(String message) { NetworkPacket np; try { np = NetworkPacket.unserialize(message); } catch (JSONException e) { Log.e("BluetoothLink/receiving", "Unable to parse message.", e); return; } if (np.getType().equals(NetworkPacket.PACKET_TYPE_ENCRYPTED)) { try { np = RsaHelper.decrypt(np, privateKey); } catch (Exception e) { Log.e("BluetoothLink/receiving", "Exception decrypting the package", e); } } if (np.hasPayloadTransferInfo()) { BluetoothSocket transferSocket = null; try { UUID transferUuid = UUID.fromString(np.getPayloadTransferInfo().getString("uuid")); transferSocket = socket.getRemoteDevice().createRfcommSocketToServiceRecord(transferUuid); transferSocket.connect(); - np.setPayload(transferSocket.getInputStream(), np.getPayloadSize()); + np.setPayload(new NetworkPacket.Payload(transferSocket.getInputStream(), np.getPayloadSize())); } catch (Exception e) { if (transferSocket != null) { try { transferSocket.close(); } catch (IOException ignored) { } } Log.e("BluetoothLink/receiving", "Unable to get payload", e); } } packageReceived(np); } }); public BluetoothLink(Context context, BluetoothSocket socket, String deviceId, BluetoothLinkProvider linkProvider) { super(context, deviceId, linkProvider); this.socket = socket; this.linkProvider = linkProvider; } public void startListening() { this.receivingThread.start(); } @Override public String getName() { return "BluetoothLink"; } @Override public BasePairingHandler getPairingHandler(Device device, BasePairingHandler.PairingHandlerCallback callback) { return new BluetoothPairingHandler(device, callback); } public void disconnect() { if (socket == null) { return; } continueAccepting = false; try { socket.close(); } catch (IOException e) { } linkProvider.disconnectedLink(this, getDeviceId(), socket); } private void sendMessage(NetworkPacket np) throws JSONException, IOException { byte[] message = np.serialize().getBytes(Charset.forName("UTF-8")); OutputStream socket = this.socket.getOutputStream(); Log.i("BluetoothLink", "Beginning to send message"); socket.write(message); Log.i("BluetoothLink", "Finished sending message"); } @Override public boolean sendPacket(NetworkPacket np, Device.SendPacketStatusCallback callback) { return sendPacketInternal(np, callback, null); } @Override public boolean sendPacketEncrypted(NetworkPacket np, Device.SendPacketStatusCallback callback, PublicKey key) { return sendPacketInternal(np, callback, key); } private boolean sendPacketInternal(NetworkPacket np, final Device.SendPacketStatusCallback callback, PublicKey key) { /*if (!isConnected()) { Log.e("BluetoothLink", "sendPacketEncrypted failed: not connected"); callback.sendFailure(new Exception("Not connected")); return; }*/ try { BluetoothServerSocket serverSocket = null; if (np.hasPayload()) { UUID transferUuid = UUID.randomUUID(); serverSocket = BluetoothAdapter.getDefaultAdapter() .listenUsingRfcommWithServiceRecord("KDE Connect Transfer", transferUuid); JSONObject payloadTransferInfo = new JSONObject(); payloadTransferInfo.put("uuid", transferUuid.toString()); np.setPayloadTransferInfo(payloadTransferInfo); } if (key != null) { try { np = RsaHelper.encrypt(np, key); } catch (Exception e) { callback.onFailure(e); return false; } } sendMessage(np); if (serverSocket != null) { BluetoothSocket transferSocket = serverSocket.accept(); try { serverSocket.close(); int idealBufferLength = 4096; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && transferSocket.getMaxReceivePacketSize() > 0) { idealBufferLength = transferSocket.getMaxReceivePacketSize(); } byte[] buffer = new byte[idealBufferLength]; int bytesRead; long progress = 0; - InputStream stream = np.getPayload(); + InputStream stream = np.getPayload().getInputStream(); while ((bytesRead = stream.read(buffer)) != -1) { progress += bytesRead; transferSocket.getOutputStream().write(buffer, 0, bytesRead); if (np.getPayloadSize() > 0) { callback.onProgressChanged((int) (100 * progress / np.getPayloadSize())); } } transferSocket.getOutputStream().flush(); stream.close(); } catch (Exception e) { callback.onFailure(e); return false; } finally { try { transferSocket.close(); } catch (IOException ignored) { } } } callback.onSuccess(); return true; } catch (Exception e) { callback.onFailure(e); return false; } } @Override public boolean linkShouldBeKeptAlive() { return receivingThread.isAlive(); } /* public boolean isConnected() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB_MR2) { return socket.isConnected(); } else { return true; } } */ } diff --git a/src/org/kde/kdeconnect/Backends/LanBackend/LanLink.java b/src/org/kde/kdeconnect/Backends/LanBackend/LanLink.java index 36523725..d8a3e013 100644 --- a/src/org/kde/kdeconnect/Backends/LanBackend/LanLink.java +++ b/src/org/kde/kdeconnect/Backends/LanBackend/LanLink.java @@ -1,297 +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) { e.printStackTrace(); } } //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 = null; 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(); + 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) { //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(); - outputStream.close(); Log.i("KDE/LanLink", "Finished sending payload ("+progress+" bytes written)"); } finally { try { server.close(); } catch (Exception e) { } try { payloadSocket.close(); } catch (Exception e) { } - try { inputStream.close(); } catch (Exception e) { } + np.getPayload().close(); try { outputStream.close(); } catch (Exception e) { } } } 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 - InputStream stream = np.getPayload(); - try { stream.close(); } catch (Exception e) { } + 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) { e.printStackTrace(); Log.e("KDE/onPacketReceived","Exception decrypting the package"); } } 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(payloadSocket.getInputStream(), np.getPayloadSize()); + np.setPayload(new NetworkPacket.Payload(payloadSocket, np.getPayloadSize())); } catch (Exception e) { try { payloadSocket.close(); } catch(Exception ignored) { } e.printStackTrace(); Log.e("KDE/LanLink", "Exception connecting to payload remote socket"); } } 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/Backends/LoopbackBackend/LoopbackLink.java b/src/org/kde/kdeconnect/Backends/LoopbackBackend/LoopbackLink.java index 9bc3d912..72c94692 100644 --- a/src/org/kde/kdeconnect/Backends/LoopbackBackend/LoopbackLink.java +++ b/src/org/kde/kdeconnect/Backends/LoopbackBackend/LoopbackLink.java @@ -1,65 +1,65 @@ /* * 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.LoopbackBackend; import android.content.Context; import org.kde.kdeconnect.Backends.BaseLink; import org.kde.kdeconnect.Backends.BaseLinkProvider; import org.kde.kdeconnect.Backends.BasePairingHandler; import org.kde.kdeconnect.Device; import org.kde.kdeconnect.NetworkPacket; import java.security.PublicKey; public class LoopbackLink extends BaseLink { public LoopbackLink(Context context, BaseLinkProvider linkProvider) { super(context, "loopback", linkProvider); } @Override public String getName() { return "LoopbackLink"; } @Override public BasePairingHandler getPairingHandler(Device device, BasePairingHandler.PairingHandlerCallback callback) { return new LoopbackPairingHandler(device, callback); } @Override public boolean sendPacket(NetworkPacket in, Device.SendPacketStatusCallback callback) { packageReceived(in); if (in.hasPayload()) { callback.onProgressChanged(0); - in.setPayload(in.getPayload(), in.getPayloadSize()); + in.setPayload(in.getPayload()); callback.onProgressChanged(100); } callback.onSuccess(); return true; } @Override public boolean sendPacketEncrypted(NetworkPacket np, Device.SendPacketStatusCallback callback, PublicKey key) { return sendPacket(np, callback); } } diff --git a/src/org/kde/kdeconnect/Helpers/SecurityHelpers/RsaHelper.java b/src/org/kde/kdeconnect/Helpers/SecurityHelpers/RsaHelper.java index 92b39256..e274aeb2 100644 --- a/src/org/kde/kdeconnect/Helpers/SecurityHelpers/RsaHelper.java +++ b/src/org/kde/kdeconnect/Helpers/SecurityHelpers/RsaHelper.java @@ -1,144 +1,144 @@ /* * Copyright 2015 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.SecurityHelpers; import android.content.Context; import android.content.SharedPreferences; import android.preference.PreferenceManager; import android.util.Base64; import android.util.Log; import org.json.JSONArray; import org.json.JSONException; import org.kde.kdeconnect.NetworkPacket; import java.nio.charset.Charset; import java.security.GeneralSecurityException; import java.security.KeyFactory; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.PrivateKey; import java.security.PublicKey; import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.X509EncodedKeySpec; import javax.crypto.Cipher; public class RsaHelper { public static void initialiseRsaKeys(Context context) { SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(context); if (!settings.contains("publicKey") || !settings.contains("privateKey")) { KeyPair keyPair; try { KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); keyGen.initialize(2048); keyPair = keyGen.genKeyPair(); } catch (Exception e) { e.printStackTrace(); Log.e("KDE/initializeRsaKeys", "Exception"); return; } byte[] publicKey = keyPair.getPublic().getEncoded(); byte[] privateKey = keyPair.getPrivate().getEncoded(); SharedPreferences.Editor edit = settings.edit(); edit.putString("publicKey", Base64.encodeToString(publicKey, 0).trim() + "\n"); edit.putString("privateKey", Base64.encodeToString(privateKey, 0)); edit.apply(); } } public static PublicKey getPublicKey(Context context) throws GeneralSecurityException { SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(context); byte[] publicKeyBytes = Base64.decode(settings.getString("publicKey", ""), 0); return KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(publicKeyBytes)); } public static PublicKey getPublicKey(Context context, String deviceId) throws GeneralSecurityException { SharedPreferences settings = context.getSharedPreferences(deviceId, Context.MODE_PRIVATE); byte[] publicKeyBytes = Base64.decode(settings.getString("publicKey", ""), 0); return KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(publicKeyBytes)); } public static PrivateKey getPrivateKey(Context context) throws GeneralSecurityException { SharedPreferences globalSettings = PreferenceManager.getDefaultSharedPreferences(context); byte[] privateKeyBytes = Base64.decode(globalSettings.getString("privateKey", ""), 0); return KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(privateKeyBytes)); } public static NetworkPacket encrypt(NetworkPacket np, PublicKey publicKey) throws GeneralSecurityException, JSONException { String serialized = np.serialize(); int chunkSize = 128; Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1PADDING"); cipher.init(Cipher.ENCRYPT_MODE, publicKey); JSONArray chunks = new JSONArray(); while (serialized.length() > 0) { if (serialized.length() < chunkSize) { chunkSize = serialized.length(); } String chunk = serialized.substring(0, chunkSize); serialized = serialized.substring(chunkSize); byte[] chunkBytes = chunk.getBytes(Charset.defaultCharset()); byte[] encryptedChunk; encryptedChunk = cipher.doFinal(chunkBytes); chunks.put(Base64.encodeToString(encryptedChunk, Base64.NO_WRAP)); } //Log.i("NetworkPacket", "Encrypted " + chunks.length()+" chunks"); NetworkPacket encrypted = new NetworkPacket(NetworkPacket.PACKET_TYPE_ENCRYPTED); encrypted.set("data", chunks); - encrypted.setPayload(np.getPayload(), np.getPayloadSize()); + encrypted.setPayload(np.getPayload()); return encrypted; } public static NetworkPacket decrypt(NetworkPacket np, PrivateKey privateKey) throws GeneralSecurityException, JSONException { JSONArray chunks = np.getJSONArray("data"); Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1PADDING"); cipher.init(Cipher.DECRYPT_MODE, privateKey); StringBuilder decryptedJson = new StringBuilder(); for (int i = 0; i < chunks.length(); i++) { byte[] encryptedChunk = Base64.decode(chunks.getString(i), Base64.NO_WRAP); String decryptedChunk = new String(cipher.doFinal(encryptedChunk)); decryptedJson.append(decryptedChunk); } NetworkPacket decrypted = NetworkPacket.unserialize(decryptedJson.toString()); - decrypted.setPayload(np.getPayload(), np.getPayloadSize()); + decrypted.setPayload(np.getPayload()); return decrypted; } } diff --git a/src/org/kde/kdeconnect/NetworkPacket.java b/src/org/kde/kdeconnect/NetworkPacket.java index 3593a03b..4ca3da8e 100644 --- a/src/org/kde/kdeconnect/NetworkPacket.java +++ b/src/org/kde/kdeconnect/NetworkPacket.java @@ -1,326 +1,365 @@ /* * 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 InputStream mPayload; + private Payload mPayload; private JSONObject mPayloadTransferInfo; - private long mPayloadSize; private NetworkPacket() { } public NetworkPacket(String type) { mId = System.currentTimeMillis(); mType = type; mBody = new JSONObject(); mPayload = null; - mPayloadSize = 0; mPayloadTransferInfo = new JSONObject(); } 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 e) { } } 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 e) { } } 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 e) { } } 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 e) { } } public JSONArray getJSONArray(String key) { return mBody.optJSONArray(key); } public void set(String key, JSONArray value) { try { mBody.put(key, value); } catch (Exception e) { } } public JSONObject getJSONObject(String key) { return mBody.optJSONObject(key); } public void set(String key, JSONObject value) { try { mBody.put(key, value); } catch (JSONException e) { } } 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 e) { } } 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 e) { } } 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 e) { } } 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 e) { } } 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", mPayloadSize); + 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.mPayloadSize = jo.getLong("payloadSize"); + np.mPayload = new Payload(jo.getLong("payloadSize")); } else { np.mPayloadTransferInfo = new JSONObject(); - np.mPayloadSize = 0; + 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(context))); np.mBody.put("outgoingCapabilities", new JSONArray(PluginFactory.getOutgoingCapabilities(context))); } catch (Exception e) { e.printStackTrace(); Log.e("NetworkPacakge", "Exception on createIdentityPacket"); } return np; } - public void setPayload(byte[] data) { - setPayload(new ByteArrayInputStream(data), data.length); - } - - public void setPayload(InputStream stream, long size) { - mPayload = stream; - mPayloadSize = size; - } + public void setPayload(Payload payload) { mPayload = payload; } - /*public void setPayload(InputStream stream) { - setPayload(stream, -1); - }*/ - - public InputStream getPayload() { + public Payload getPayload() { return mPayload; } public long getPayloadSize() { - return mPayloadSize; + return mPayload == null ? 0 : mPayload.payloadSize; } public boolean hasPayload() { - return (mPayloadSize != 0); + 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; + + 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/MprisPlugin/AlbumArtCache.java b/src/org/kde/kdeconnect/Plugins/MprisPlugin/AlbumArtCache.java index f87256d9..7433342b 100644 --- a/src/org/kde/kdeconnect/Plugins/MprisPlugin/AlbumArtCache.java +++ b/src/org/kde/kdeconnect/Plugins/MprisPlugin/AlbumArtCache.java @@ -1,507 +1,497 @@ /* * Copyright 2017 Matthijs Tijink * * 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.MprisPlugin; import android.content.Context; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.net.ConnectivityManager; import android.os.AsyncTask; import android.os.Build; import android.support.v4.util.LruCache; import android.util.Log; import com.jakewharton.disklrucache.DiskLruCache; +import org.kde.kdeconnect.NetworkPacket; + import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.URL; import java.net.URLDecoder; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; /** * Handles the cache for album art */ final class AlbumArtCache { private static final class MemoryCacheItem { boolean failedFetch; Bitmap albumArt; } /** * An in-memory cache for album art bitmaps. Holds at most 10 entries (to prevent too much memory usage) * Also remembers failure to fetch urls. */ private static final LruCache memoryCache = new LruCache<>(10); /** * An on-disk cache for album art bitmaps. */ private static DiskLruCache diskCache; /** * Used to check if the connection is metered */ private static ConnectivityManager connectivityManager; /** * A list of urls yet to be fetched. */ private static final ArrayList fetchUrlList = new ArrayList<>(); /** * A list of urls currently being fetched */ private static final ArrayList isFetchingList = new ArrayList<>(); /** * A integer indicating how many fetches are in progress. */ private static int numFetching = 0; /** * A list of plugins to notify on fetched album art */ private static final ArrayList registeredPlugins = new ArrayList<>(); /** * Initializes the disk cache. Needs to be called at least once before trying to use the cache * * @param context The context */ static void initializeDiskCache(Context context) { if (diskCache != null) return; File cacheDir = new File(context.getCacheDir(), "album_art"); int versionCode; try { PackageInfo info = context.getPackageManager().getPackageInfo(context.getPackageName(), 0); versionCode = info.versionCode; //Initialize the disk cache with a limit of 5 MB storage (fits ~830 images, taking Spotify as reference) diskCache = DiskLruCache.open(cacheDir, versionCode, 1, 1000 * 1000 * 5); } catch (PackageManager.NameNotFoundException e) { throw new AssertionError(e); } catch (IOException e) { Log.e("KDE/Mpris/AlbumArtCache", "Could not open the album art disk cache!", e); } connectivityManager = (ConnectivityManager) context.getApplicationContext().getSystemService(Context.CONNECTIVITY_SERVICE); } /** * Registers a mpris plugin, such that it gets notified of fetched album art * * @param mpris The mpris plugin */ static void registerPlugin(MprisPlugin mpris) { registeredPlugins.add(mpris); } /** * Deregister a mpris plugin * * @param mpris The mpris plugin */ static void deregisterPlugin(MprisPlugin mpris) { registeredPlugins.remove(mpris); } /** * Get the album art for the given url. Currently only handles http(s) urls. * If it's not in the cache, will initiate a request to fetch it. * * @param albumUrl The album art url * @return A bitmap for the album art. Can be null if not (yet) found */ static Bitmap getAlbumArt(String albumUrl, MprisPlugin plugin, String player) { //If the url is invalid, return "no album art" if (albumUrl == null || albumUrl.isEmpty()) { return null; } URL url; try { url = new URL(albumUrl); } catch (MalformedURLException e) { //Invalid url, so just return "no album art" //Shouldn't happen (checked on receival of the url), but just to be sure return null; } //We currently only support http(s) and file urls if (!url.getProtocol().equals("http") && !url.getProtocol().equals("https") && !url.getProtocol().equals("file")) { return null; } //First, check the in-memory cache if (memoryCache.get(albumUrl) != null) { MemoryCacheItem item = memoryCache.get(albumUrl); //Do not retry failed fetches if (item.failedFetch) { return null; } else { return item.albumArt; } } //If not found, check the disk cache if (diskCache == null) { Log.e("KDE/Mpris/AlbumArtCache", "The disk cache is not intialized!"); return null; } try { DiskLruCache.Snapshot item = diskCache.get(urlToDiskCacheKey(albumUrl)); if (item != null) { Bitmap result = BitmapFactory.decodeStream(item.getInputStream(0)); item.close(); MemoryCacheItem memItem = new MemoryCacheItem(); if (result != null) { memItem.failedFetch = false; memItem.albumArt = result; } else { //Invalid bitmap, so remember it as a "failed fetch" and remove it from the disk cache memItem.failedFetch = true; memItem.albumArt = null; diskCache.remove(urlToDiskCacheKey(albumUrl)); Log.d("KDE/Mpris/AlbumArtCache", "Invalid image: " + albumUrl); } memoryCache.put(albumUrl, memItem); return result; } } catch (IOException e) { return null; } /* If not found, we have not tried fetching it (recently), or a fetch is in-progress. Either way, just add it to the fetch queue and starting fetching it if no fetch is running. */ if ("file".equals(url.getProtocol())) { //Special-case file, since we need to fetch it from the remote if (isFetchingList.contains(url)) return null; if (!plugin.askTransferAlbumArt(albumUrl, player)) { //It doesn't support transferring the art, so mark it as failed in the memory cache MemoryCacheItem cacheItem = new MemoryCacheItem(); cacheItem.failedFetch = true; cacheItem.albumArt = null; memoryCache.put(url.toString(), cacheItem); } } else { fetchUrl(url); } return null; } /** * Fetches an album art url and puts it in the cache * * @param url The url */ private static void fetchUrl(URL url) { //We need the disk cache for this if (diskCache == null) { Log.e("KDE/Mpris/AlbumArtCache", "The disk cache is not intialized!"); return; } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { //Only download art on unmetered networks (wifi etc.) if (connectivityManager.isActiveNetworkMetered()) { return; } } //Only fetch an URL if we're not fetching it already if (fetchUrlList.contains(url) || isFetchingList.contains(url)) { return; } fetchUrlList.add(url); initiateFetch(); } private static final class FetchURLTask extends AsyncTask { private final URL url; - private InputStream input; + private NetworkPacket.Payload payload; private final DiskLruCache.Editor cacheItem; private OutputStream output; /** * Initialize an url fetch * * @param url The url being fetched - * @param payloadInput A payload input stream (if from the connected device). null if fetched from http(s) + * @param payload A NetworkPacket Payload (if from the connected device). null if fetched from http(s) * @param cacheItem The disk cache item to edit */ - FetchURLTask(URL url, InputStream payloadInput, DiskLruCache.Editor cacheItem) throws IOException { + FetchURLTask(URL url, NetworkPacket.Payload payload, DiskLruCache.Editor cacheItem) throws IOException { this.url = url; - this.input = payloadInput; + this.payload = payload; this.cacheItem = cacheItem; output = cacheItem.newOutputStream(0); } /** * Opens the http(s) connection * * @return True if succeeded */ - private boolean openHttp() throws IOException { + private InputStream openHttp() throws IOException { //Default android behaviour does not follow https -> http urls, so do this manually if (!url.getProtocol().equals("http") && !url.getProtocol().equals("https")) { throw new AssertionError("Invalid url: not http(s) in background album art fetch"); } URL currentUrl = url; HttpURLConnection connection; for (int i = 0; i < 5; ++i) { connection = (HttpURLConnection) currentUrl.openConnection(); connection.setConnectTimeout(10000); connection.setReadTimeout(10000); connection.setInstanceFollowRedirects(false); switch (connection.getResponseCode()) { case HttpURLConnection.HTTP_MOVED_PERM: case HttpURLConnection.HTTP_MOVED_TEMP: String location = connection.getHeaderField("Location"); location = URLDecoder.decode(location, "UTF-8"); currentUrl = new URL(currentUrl, location); // Deal with relative URLs //Again, only support http(s) if (!currentUrl.getProtocol().equals("http") && !currentUrl.getProtocol().equals("https")) { - return false; + return null; } connection.disconnect(); continue; } //Found a non-redirecting connection, so do something with it - input = connection.getInputStream(); - return true; + return connection.getInputStream(); } - return false; + return null; } @Override protected Boolean doInBackground(Void... params) { - try { - //See if we need to open a http(s) connection here, or if we use a payload input stream + //See if we need to open a http(s) connection here, or if we use a payload input stream + try (InputStream input = payload == null ? openHttp() : payload.getInputStream()) { if (input == null) { - if (!openHttp()) { - return false; - } + return false; } byte[] buffer = new byte[4096]; int bytesRead; while ((bytesRead = input.read(buffer)) != -1) { output.write(buffer, 0, bytesRead); } output.flush(); output.close(); return true; } catch (IOException e) { return false; + } finally { + if (payload != null) { + payload.close(); + } } } @Override protected void onPostExecute(Boolean success) { try { if (success) { cacheItem.commit(); } else { cacheItem.abort(); } } catch (IOException e) { success = false; Log.e("KDE/Mpris/AlbumArtCache", "Problem with the disk cache", e); } if (success) { //Now it's in the disk cache, the getAlbumArt() function should be able to read it //So notify the mpris plugins of the fetched art for (MprisPlugin mpris : registeredPlugins) { mpris.fetchedAlbumArt(url.toString()); } } else { //Mark the fetch as failed in the memory cache MemoryCacheItem cacheItem = new MemoryCacheItem(); cacheItem.failedFetch = true; cacheItem.albumArt = null; memoryCache.put(url.toString(), cacheItem); } //Remove the url from the fetching list isFetchingList.remove(url); //Fetch the next url (if any) --numFetching; initiateFetch(); } } /** * Does the actual fetching and makes sure only not too many fetches are running at the same time */ private static void initiateFetch() { if (numFetching >= 2) return; if (fetchUrlList.isEmpty()) return; //Fetch the last-requested url first, it will probably be needed first URL url = fetchUrlList.get(fetchUrlList.size() - 1); //Remove the url from the to-fetch list fetchUrlList.remove(url); if ("file".equals(url.getProtocol())) { throw new AssertionError("Not file urls should be possible here!"); } //Download the album art ourselves ++numFetching; //Add the url to the currently-fetching list isFetchingList.add(url); try { DiskLruCache.Editor cacheItem = diskCache.edit(urlToDiskCacheKey(url.toString())); if (cacheItem == null) { Log.e("KDE/Mpris/AlbumArtCache", "Two disk cache edits happened at the same time, should be impossible!"); --numFetching; return; } //Do the actual fetch in the background new FetchURLTask(url, null, cacheItem).execute(); } catch (IOException e) { Log.e("KDE/Mpris/AlbumArtCache", "Problems with the disk cache", e); --numFetching; } } /** * The disk cache requires mostly alphanumeric characters, and at most 64 characters. * So hash the url to get a valid key * * @param url The url * @return A valid disk cache key */ private static String urlToDiskCacheKey(String url) { MessageDigest hasher; try { hasher = MessageDigest.getInstance("MD5"); } catch (NoSuchAlgorithmException e) { //Should always be available throw new AssertionError(e); } StringBuilder builder = new StringBuilder(); for (byte singleByte : hasher.digest(url.getBytes())) { builder.append(String.format("%02x", singleByte)); } return builder.toString(); } /** * Transfer an asked-for album art payload to the disk cache. * * @param albumUrl The url of the album art (should be a file:// url) * @param payload The payload input stream */ - static void payloadToDiskCache(String albumUrl, InputStream payload) { + static void payloadToDiskCache(String albumUrl, NetworkPacket.Payload payload) { //We need the disk cache for this - if (diskCache == null) { - Log.e("KDE/Mpris/AlbumArtCache", "The disk cache is not intialized!"); - try { - payload.close(); - } catch (IOException ignored) {} + if (payload == null) { return; } - if (payload == null) { + + if (diskCache == null) { + Log.e("KDE/Mpris/AlbumArtCache", "The disk cache is not intialized!"); + payload.close(); return; } URL url; try { url = new URL(albumUrl); } catch (MalformedURLException e) { //Shouldn't happen (checked on receival of the url), but just to be sure - try { - payload.close(); - } catch (IOException ignored) {} + payload.close(); return; } if (!"file".equals(url.getProtocol())) { //Shouldn't happen (otherwise we wouldn't have asked for the payload), but just to be sure - try { - payload.close(); - } catch (IOException ignored) {} + payload.close(); return; } //Only fetch the URL if we're not fetching it already if (isFetchingList.contains(url)) { - try { - payload.close(); - } catch (IOException ignored) {} + payload.close(); return; } //Check if we already have this art try { if (memoryCache.get(albumUrl) != null || diskCache.get(urlToDiskCacheKey(albumUrl)) != null) { - try { - payload.close(); - } catch (IOException ignored) {} + payload.close(); return; } } catch (IOException e) { Log.e("KDE/Mpris/AlbumArtCache", "Disk cache problem!", e); - try { - payload.close(); - } catch (IOException ignored) {} + payload.close(); return; } //Add it to the currently-fetching list isFetchingList.add(url); ++numFetching; try { DiskLruCache.Editor cacheItem = diskCache.edit(urlToDiskCacheKey(url.toString())); if (cacheItem == null) { Log.e("KDE/Mpris/AlbumArtCache", "Two disk cache edits happened at the same time, should be impossible!"); --numFetching; - try { - payload.close(); - } catch (IOException ignored) {} + payload.close(); return; } //Do the actual fetch in the background new FetchURLTask(url, payload, cacheItem).execute(); } catch (IOException e) { Log.e("KDE/Mpris/AlbumArtCache", "Problems with the disk cache", e); --numFetching; } } } diff --git a/src/org/kde/kdeconnect/Plugins/NotificationsPlugin/NotificationsPlugin.java b/src/org/kde/kdeconnect/Plugins/NotificationsPlugin/NotificationsPlugin.java index f5238826..4139e4d3 100644 --- a/src/org/kde/kdeconnect/Plugins/NotificationsPlugin/NotificationsPlugin.java +++ b/src/org/kde/kdeconnect/Plugins/NotificationsPlugin/NotificationsPlugin.java @@ -1,591 +1,591 @@ /* * 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.NotificationsPlugin; import android.annotation.TargetApi; import android.app.Activity; import android.app.AlertDialog; import android.app.Notification; import android.app.PendingIntent; import android.app.RemoteInput; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.drawable.Drawable; import android.graphics.drawable.Icon; import android.os.Build; import android.os.Bundle; import android.provider.Settings; import android.service.notification.StatusBarNotification; import android.support.annotation.RequiresApi; import android.support.v4.app.NotificationCompat; import android.text.SpannableString; import android.util.Log; import org.kde.kdeconnect.Helpers.AppsHelper; import org.kde.kdeconnect.NetworkPacket; import org.kde.kdeconnect.Plugins.Plugin; import org.kde.kdeconnect.UserInterface.DeviceSettingsActivity; import org.kde.kdeconnect.UserInterface.MainActivity; import org.kde.kdeconnect_tp.R; import java.io.ByteArrayOutputStream; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2) public class NotificationsPlugin extends Plugin implements NotificationReceiver.NotificationListener { private final static String PACKET_TYPE_NOTIFICATION = "kdeconnect.notification"; private final static String PACKET_TYPE_NOTIFICATION_REQUEST = "kdeconnect.notification.request"; private final static String PACKET_TYPE_NOTIFICATION_REPLY = "kdeconnect.notification.reply"; private AppDatabase appDatabase; private Set currentNotifications; private Map pendingIntents; private boolean serviceReady; @Override public String getDisplayName() { return context.getResources().getString(R.string.pref_plugin_notifications); } @Override public String getDescription() { return context.getResources().getString(R.string.pref_plugin_notifications_desc); } @Override public boolean hasSettings() { return true; } @Override public void startPreferencesActivity(final DeviceSettingsActivity parentActivity) { if (hasPermission()) { Intent intent = new Intent(parentActivity, NotificationFilterActivity.class); parentActivity.startActivity(intent); } else { getErrorDialog(parentActivity).show(); } } private boolean hasPermission() { String notificationListenerList = Settings.Secure.getString(context.getContentResolver(), "enabled_notification_listeners"); return (notificationListenerList != null && notificationListenerList.contains(context.getPackageName())); } @Override public boolean onCreate() { if (!hasPermission()) return false; pendingIntents = new HashMap<>(); currentNotifications = new HashSet<>(); appDatabase = new AppDatabase(context, true); NotificationReceiver.RunCommand(context, service -> { service.addListener(NotificationsPlugin.this); serviceReady = service.isConnected(); if (serviceReady) { sendCurrentNotifications(service); } }); return true; } @Override public void onDestroy() { NotificationReceiver.RunCommand(context, service -> service.removeListener(NotificationsPlugin.this)); } @Override public void onListenerConnected(NotificationReceiver service) { serviceReady = true; sendCurrentNotifications(service); } @Override public void onNotificationRemoved(StatusBarNotification statusBarNotification) { if (statusBarNotification == null) { Log.w("onNotificationRemoved", "notification is null"); return; } String id = getNotificationKeyCompat(statusBarNotification); NetworkPacket np = new NetworkPacket(PACKET_TYPE_NOTIFICATION); np.set("id", id); np.set("isCancel", true); device.sendPacket(np); currentNotifications.remove(id); } @Override public void onNotificationPosted(StatusBarNotification statusBarNotification) { sendNotification(statusBarNotification); } private void sendNotification(StatusBarNotification statusBarNotification) { Notification notification = statusBarNotification.getNotification(); if ((notification.flags & Notification.FLAG_FOREGROUND_SERVICE) != 0 || (notification.flags & Notification.FLAG_ONGOING_EVENT) != 0 || (notification.flags & Notification.FLAG_LOCAL_ONLY) != 0 || (notification.flags & NotificationCompat.FLAG_GROUP_SUMMARY) != 0) //The notification that groups other notifications { //This is not a notification we want! return; } if (!appDatabase.isEnabled(statusBarNotification.getPackageName())) { return; // we dont want notification from this app } String key = getNotificationKeyCompat(statusBarNotification); String packageName = statusBarNotification.getPackageName(); String appName = AppsHelper.appNameLookup(context, packageName); if ("com.facebook.orca".equals(packageName) && (statusBarNotification.getId() == 10012) && "Messenger".equals(appName) && notification.tickerText == null) { //HACK: Hide weird Facebook empty "Messenger" notification that is actually not shown in the phone return; } if ("com.android.systemui".equals(packageName) && "low_battery".equals(statusBarNotification.getTag())) { //HACK: Android low battery notification are posted again every few seconds. Ignore them, as we already have a battery indicator. return; } NetworkPacket np = new NetworkPacket(PACKET_TYPE_NOTIFICATION); if (packageName.equals("org.kde.kdeconnect_tp")) { //Make our own notifications silent :) np.set("silent", true); np.set("requestAnswer", true); //For compatibility with old desktop versions of KDE Connect that don't support "silent" } boolean isUpdate = currentNotifications.contains(key); if (!isUpdate) { //If it's an update, the other end should have the icon already: no need to extract it and create the payload again try { Bitmap appIcon = null; Context foreignContext = context.createPackageContext(statusBarNotification.getPackageName(), 0); if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { appIcon = iconToBitmap(foreignContext, notification.getLargeIcon()); } else { appIcon = notification.largeIcon; } //appIcon = drawableToBitmap(context.getResources().getDrawable(R.drawable.icon)); if (appIcon == null) { if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { appIcon = iconToBitmap(foreignContext, notification.getSmallIcon()); } else { PackageManager pm = context.getPackageManager(); Resources foreignResources = pm.getResourcesForApplication(statusBarNotification.getPackageName()); Drawable foreignIcon = foreignResources.getDrawable(notification.icon); appIcon = drawableToBitmap(foreignIcon); } } if (appIcon != null) { ByteArrayOutputStream outStream = new ByteArrayOutputStream(); appIcon.compress(Bitmap.CompressFormat.PNG, 90, outStream); byte[] bitmapData = outStream.toByteArray(); Log.e("PAYLOAD", "PAYLOAD: " + getChecksum(bitmapData)); - np.setPayload(bitmapData); + np.setPayload(new NetworkPacket.Payload(bitmapData)); np.set("payloadHash", getChecksum(bitmapData)); } } catch (Exception e) { e.printStackTrace(); Log.e("NotificationsPlugin", "Error retrieving icon"); } } else { currentNotifications.add(key); } RepliableNotification rn = extractRepliableNotification(statusBarNotification); if (rn.pendingIntent != null) { np.set("requestReplyId", rn.id); pendingIntents.put(rn.id, rn); } np.set("id", key); np.set("appName", appName == null ? packageName : appName); np.set("isClearable", statusBarNotification.isClearable()); np.set("ticker", getTickerText(notification)); np.set("title", getNotificationTitle(notification)); np.set("text", getNotificationText(notification)); np.set("time", Long.toString(statusBarNotification.getPostTime())); device.sendPacket(np); } private Bitmap drawableToBitmap(Drawable drawable) { if (drawable == null) return null; Bitmap res; if (drawable.getIntrinsicWidth() > 128 || drawable.getIntrinsicHeight() > 128) { res = Bitmap.createBitmap(96, 96, Bitmap.Config.ARGB_8888); } else if (drawable.getIntrinsicWidth() <= 64 || drawable.getIntrinsicHeight() <= 64) { res = Bitmap.createBitmap(96, 96, Bitmap.Config.ARGB_8888); } else { res = Bitmap.createBitmap(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); } Canvas canvas = new Canvas(res); drawable.setBounds(0, 0, res.getWidth(), res.getHeight()); drawable.draw(canvas); return res; } @RequiresApi(Build.VERSION_CODES.M) private Bitmap iconToBitmap(Context foreignContext, Icon icon) { if (icon == null) return null; return drawableToBitmap(icon.loadDrawable(foreignContext)); } @RequiresApi(api = Build.VERSION_CODES.KITKAT_WATCH) private void replyToNotification(String id, String message) { if (pendingIntents.isEmpty() || !pendingIntents.containsKey(id)) { Log.e("NotificationsPlugin", "No such notification"); return; } RepliableNotification repliableNotification = pendingIntents.get(id); if (repliableNotification == null) { Log.e("NotificationsPlugin", "No such notification"); return; } RemoteInput[] remoteInputs = new RemoteInput[repliableNotification.remoteInputs.size()]; Intent localIntent = new Intent(); localIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); Bundle localBundle = new Bundle(); int i = 0; for (RemoteInput remoteIn : repliableNotification.remoteInputs) { getDetailsOfNotification(remoteIn); remoteInputs[i] = remoteIn; localBundle.putCharSequence(remoteInputs[i].getResultKey(), message); i++; } RemoteInput.addResultsToIntent(remoteInputs, localIntent, localBundle); try { repliableNotification.pendingIntent.send(context, 0, localIntent); } catch (PendingIntent.CanceledException e) { Log.e("NotificationPlugin", "replyToNotification error: " + e.getMessage()); } pendingIntents.remove(id); } @RequiresApi(api = Build.VERSION_CODES.KITKAT_WATCH) private void getDetailsOfNotification(RemoteInput remoteInput) { //Some more details of RemoteInput... no idea what for but maybe it will be useful at some point String resultKey = remoteInput.getResultKey(); String label = remoteInput.getLabel().toString(); Boolean canFreeForm = remoteInput.getAllowFreeFormInput(); if (remoteInput.getChoices() != null && remoteInput.getChoices().length > 0) { String[] possibleChoices = new String[remoteInput.getChoices().length]; for (int i = 0; i < remoteInput.getChoices().length; i++) { possibleChoices[i] = remoteInput.getChoices()[i].toString(); } } } private String getNotificationTitle(Notification notification) { final String TITLE_KEY = "android.title"; final String TEXT_KEY = "android.text"; String title = ""; if (notification != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { try { Bundle extras = notification.extras; title = extractStringFromExtra(extras, TITLE_KEY); } catch (Exception e) { Log.w("NotificationPlugin", "problem parsing notification extras for " + notification.tickerText); e.printStackTrace(); } } } return title; } private RepliableNotification extractRepliableNotification(StatusBarNotification statusBarNotification) { RepliableNotification repliableNotification = new RepliableNotification(); if (statusBarNotification != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { try { if (statusBarNotification.getNotification().actions != null) { for (Notification.Action act : statusBarNotification.getNotification().actions) { if (act != null && act.getRemoteInputs() != null) { // Is a reply repliableNotification.remoteInputs.addAll(Arrays.asList(act.getRemoteInputs())); repliableNotification.pendingIntent = act.actionIntent; break; } } repliableNotification.packageName = statusBarNotification.getPackageName(); repliableNotification.tag = statusBarNotification.getTag();//TODO find how to pass Tag with sending PendingIntent, might fix Hangout problem } } catch (Exception e) { Log.w("NotificationPlugin", "problem extracting notification wear for " + statusBarNotification.getNotification().tickerText); e.printStackTrace(); } } } return repliableNotification; } private String getNotificationText(Notification notification) { final String TEXT_KEY = "android.text"; String text = ""; if (notification != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { try { Bundle extras = notification.extras; Object extraTextExtra = extras.get(TEXT_KEY); if (extraTextExtra != null) text = extraTextExtra.toString(); } catch (Exception e) { Log.w("NotificationPlugin", "problem parsing notification extras for " + notification.tickerText); e.printStackTrace(); } } } return text; } private static String extractStringFromExtra(Bundle extras, String key) { Object extra = extras.get(key); if (extra == null) { return null; } else if (extra instanceof String) { return (String) extra; } else if (extra instanceof SpannableString) { return extra.toString(); } else { Log.e("NotificationsPlugin", "Don't know how to extract text from extra of type: " + extra.getClass().getCanonicalName()); return null; } } /** * Returns the ticker text of the notification. * If device android version is KitKat or newer, the title and text of the notification is used * instead the ticker text. */ private String getTickerText(Notification notification) { final String TITLE_KEY = "android.title"; final String TEXT_KEY = "android.text"; String ticker = ""; if (notification != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { try { Bundle extras = notification.extras; String extraTitle = extractStringFromExtra(extras, TITLE_KEY); String extraText = extractStringFromExtra(extras, TEXT_KEY); if (extraTitle != null && extraText != null && !extraText.isEmpty()) { ticker = extraTitle + ": " + extraText; } else if (extraTitle != null) { ticker = extraTitle; } else if (extraText != null) { ticker = extraText; } } catch (Exception e) { Log.w("NotificationPlugin", "problem parsing notification extras for " + notification.tickerText); e.printStackTrace(); } } if (ticker.isEmpty()) { ticker = (notification.tickerText != null) ? notification.tickerText.toString() : ""; } } return ticker; } private void sendCurrentNotifications(NotificationReceiver service) { StatusBarNotification[] notifications = service.getActiveNotifications(); for (StatusBarNotification notification : notifications) { sendNotification(notification); } } @Override public boolean onPacketReceived(final NetworkPacket np) { if (np.getBoolean("request")) { if (serviceReady) { NotificationReceiver.RunCommand(context, this::sendCurrentNotifications); } } else if (np.has("cancel")) { final String dismissedId = np.getString("cancel"); currentNotifications.remove(dismissedId); NotificationReceiver.RunCommand(context, service -> { cancelNotificationCompat(service, dismissedId); }); } else if (np.has("requestReplyId") && np.has("message")) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) { replyToNotification(np.getString("requestReplyId"), np.getString("message")); } } return true; } @Override public AlertDialog getErrorDialog(final Activity deviceActivity) { return new AlertDialog.Builder(deviceActivity) .setTitle(R.string.pref_plugin_notifications) .setMessage(R.string.no_permissions) .setPositiveButton(R.string.open_settings, (dialogInterface, i) -> { Intent intent = new Intent("android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS"); deviceActivity.startActivityForResult(intent, MainActivity.RESULT_NEEDS_RELOAD); }) .setNegativeButton(R.string.cancel, (dialogInterface, i) -> { //Do nothing }) .create(); } @Override public String[] getSupportedPacketTypes() { return new String[]{PACKET_TYPE_NOTIFICATION_REQUEST, PACKET_TYPE_NOTIFICATION_REPLY}; } @Override public String[] getOutgoingPacketTypes() { return new String[]{PACKET_TYPE_NOTIFICATION}; } //For compat with API<21, because lollipop changed the way to cancel notifications private static void cancelNotificationCompat(NotificationReceiver service, String compatKey) { if (Build.VERSION.SDK_INT >= 21) { service.cancelNotification(compatKey); } else { int first = compatKey.indexOf(':'); if (first == -1) { Log.e("cancelNotificationCompa", "Not formatted like a notification key: " + compatKey); return; } int last = compatKey.lastIndexOf(':'); String packageName = compatKey.substring(0, first); String tag = compatKey.substring(first + 1, last); if (tag.length() == 0) tag = null; String idString = compatKey.substring(last + 1); int id; try { id = Integer.parseInt(idString); } catch (Exception e) { id = 0; } service.cancelNotification(packageName, tag, id); } } private static String getNotificationKeyCompat(StatusBarNotification statusBarNotification) { String result; // first check if it's one of our remoteIds String tag = statusBarNotification.getTag(); if (tag != null && tag.startsWith("kdeconnectId:")) result = Integer.toString(statusBarNotification.getId()); else if (Build.VERSION.SDK_INT >= 21) { result = statusBarNotification.getKey(); } else { String packageName = statusBarNotification.getPackageName(); int id = statusBarNotification.getId(); String safePackageName = (packageName == null) ? "" : packageName; String safeTag = (tag == null) ? "" : tag; result = safePackageName + ":" + safeTag + ":" + id; } return result; } private String getChecksum(byte[] data) { try { MessageDigest md = MessageDigest.getInstance("MD5"); md.update(data); return bytesToHex(md.digest()); } catch (NoSuchAlgorithmException e) { Log.e("KDEConnect", "Error while generating checksum", e); } return null; } private static String bytesToHex(byte[] bytes) { char[] hexArray = "0123456789ABCDEF".toCharArray(); char[] hexChars = new char[bytes.length * 2]; for (int j = 0; j < bytes.length; j++) { int v = bytes[j] & 0xFF; hexChars[j * 2] = hexArray[v >>> 4]; hexChars[j * 2 + 1] = hexArray[v & 0x0F]; } return new String(hexChars).toLowerCase(); } @Override public int getMinSdk() { return Build.VERSION_CODES.JELLY_BEAN_MR2; } } diff --git a/src/org/kde/kdeconnect/Plugins/ReceiveNotificationsPlugin/ReceiveNotificationsPlugin.java b/src/org/kde/kdeconnect/Plugins/ReceiveNotificationsPlugin/ReceiveNotificationsPlugin.java index a3c99359..5ea5ec1f 100644 --- a/src/org/kde/kdeconnect/Plugins/ReceiveNotificationsPlugin/ReceiveNotificationsPlugin.java +++ b/src/org/kde/kdeconnect/Plugins/ReceiveNotificationsPlugin/ReceiveNotificationsPlugin.java @@ -1,135 +1,132 @@ /* * 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.ReceiveNotificationsPlugin; import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.support.v4.app.NotificationCompat; 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.UserInterface.MainActivity; import org.kde.kdeconnect_tp.R; import java.io.InputStream; public class ReceiveNotificationsPlugin extends Plugin { private final static String PACKET_TYPE_NOTIFICATION = "kdeconnect.notification"; private final static String PACKET_TYPE_NOTIFICATION_REQUEST = "kdeconnect.notification.request"; @Override public String getDisplayName() { return context.getResources().getString(R.string.pref_plugin_receive_notifications); } @Override public String getDescription() { return context.getResources().getString(R.string.pref_plugin_receive_notifications_desc); } @Override public boolean isEnabledByDefault() { return false; } @Override public boolean onCreate() { // request all existing notifications NetworkPacket np = new NetworkPacket(PACKET_TYPE_NOTIFICATION_REQUEST); np.set("request", true); device.sendPacket(np); return true; } @Override public boolean onPacketReceived(final NetworkPacket np) { if (!np.has("ticker") || !np.has("appName") || !np.has("id")) { Log.e("NotificationsPlugin", "Received notification package lacks properties"); } else { PendingIntent resultPendingIntent = PendingIntent.getActivity( context, 0, new Intent(context, MainActivity.class), PendingIntent.FLAG_UPDATE_CURRENT ); Bitmap largeIcon = null; if (np.hasPayload()) { int width = 64; // default icon dimensions int height = 64; width = context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_width); height = context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_height); - final InputStream input = np.getPayload(); - largeIcon = BitmapFactory.decodeStream(np.getPayload()); - try { - input.close(); - } catch (Exception e) { - } + final InputStream input = np.getPayload().getInputStream(); + largeIcon = BitmapFactory.decodeStream(input); + np.getPayload().close(); + if (largeIcon != null) { //Log.i("NotificationsPlugin", "hasPayload: size=" + largeIcon.getWidth() + "/" + largeIcon.getHeight() + " opti=" + width + "/" + height); if (largeIcon.getWidth() > width || largeIcon.getHeight() > height) { // older API levels don't scale notification icons automatically, therefore: largeIcon = Bitmap.createScaledBitmap(largeIcon, width, height, false); } } } NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); Notification noti = new NotificationCompat.Builder(context, NotificationHelper.Channels.DEFAULT) .setContentTitle(np.getString("appName")) .setContentText(np.getString("ticker")) .setContentIntent(resultPendingIntent) .setTicker(np.getString("ticker")) .setSmallIcon(R.drawable.ic_notification) .setLargeIcon(largeIcon) .setAutoCancel(true) .setLocalOnly(true) // to avoid bouncing the notification back to other kdeconnect nodes .setDefaults(Notification.DEFAULT_ALL) .build(); NotificationHelper.notifyCompat(notificationManager, "kdeconnectId:" + np.getString("id", "0"), np.getInt("id", 0), noti); } return true; } @Override public String[] getSupportedPacketTypes() { return new String[]{PACKET_TYPE_NOTIFICATION}; } @Override public String[] getOutgoingPacketTypes() { return new String[]{PACKET_TYPE_NOTIFICATION_REQUEST}; } - } diff --git a/src/org/kde/kdeconnect/Plugins/SharePlugin/ReceiveFileRunnable.java b/src/org/kde/kdeconnect/Plugins/SharePlugin/ReceiveFileRunnable.java index 9636dcc2..32301969 100644 --- a/src/org/kde/kdeconnect/Plugins/SharePlugin/ReceiveFileRunnable.java +++ b/src/org/kde/kdeconnect/Plugins/SharePlugin/ReceiveFileRunnable.java @@ -1,92 +1,92 @@ /* * 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.os.Handler; import android.os.Looper; import java.io.IOException; +import java.io.InputStream; public class ReceiveFileRunnable implements Runnable { interface CallBack { void onProgress(ShareInfo info, int progress); void onSuccess(ShareInfo info); void onError(ShareInfo info, Throwable error); } private final ShareInfo info; private final CallBack callBack; private final Handler handler; ReceiveFileRunnable(ShareInfo info, CallBack callBack) { this.info = info; this.callBack = callBack; this.handler = new Handler(Looper.getMainLooper()); } @Override public void run() { try { byte data[] = new byte[4096]; long received = 0, prevProgressPercentage = 0; int count; callBack.onProgress(info, 0); - while ((count = info.inputStream.read(data)) >= 0) { + InputStream inputStream = info.payload.getInputStream(); + + while ((count = inputStream.read(data)) >= 0) { received += count; if (received > info.fileSize) { break; } info.outputStream.write(data, 0, count); if (info.fileSize > 0) { long progressPercentage = (received * 100 / info.fileSize); if (progressPercentage != prevProgressPercentage) { prevProgressPercentage = progressPercentage; handler.post(() -> callBack.onProgress(info, (int)progressPercentage)); } } //else Log.e("SharePlugin", "Infinite loop? :D"); } info.outputStream.flush(); if (received != info.fileSize) { throw new RuntimeException("Received:" + received + " bytes, expected: " + info.fileSize + " bytes"); } handler.post(() -> callBack.onSuccess(info)); } catch (IOException e) { handler.post(() -> callBack.onError(info, e)); } finally { - try { - info.inputStream.close(); - } catch (IOException e) { - } + info.payload.close(); + try { info.outputStream.close(); - } catch (IOException e) { - } + } catch (IOException ignored) {} } } } diff --git a/src/org/kde/kdeconnect/Plugins/SharePlugin/ShareInfo.java b/src/org/kde/kdeconnect/Plugins/SharePlugin/ShareInfo.java index 61d077f5..026b80f3 100644 --- a/src/org/kde/kdeconnect/Plugins/SharePlugin/ShareInfo.java +++ b/src/org/kde/kdeconnect/Plugins/SharePlugin/ShareInfo.java @@ -1,64 +1,65 @@ /* * 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.support.v4.provider.DocumentFile; -import java.io.InputStream; +import org.kde.kdeconnect.NetworkPacket; + import java.io.OutputStream; class ShareInfo { String fileName; long fileSize; int currentFileNumber; DocumentFile fileDocument; - InputStream inputStream; + NetworkPacket.Payload payload; OutputStream outputStream; boolean shouldOpen; private final Object lock = new Object(); // To protect access to numberOfFiles and totalTransferSize private int numberOfFiles; private long totalTransferSize; int numberOfFiles() { synchronized (lock) { return numberOfFiles; } } void setNumberOfFiles(int numberOfFiles) { synchronized (lock) { this.numberOfFiles = numberOfFiles; } } long totalTransferSize() { synchronized (lock) { return totalTransferSize; } } void setTotalTransferSize(long totalTransferSize) { synchronized (lock) { this.totalTransferSize = totalTransferSize; } } } diff --git a/src/org/kde/kdeconnect/Plugins/SharePlugin/SharePlugin.java b/src/org/kde/kdeconnect/Plugins/SharePlugin/SharePlugin.java index 9c5382dc..013061a2 100644 --- a/src/org/kde/kdeconnect/Plugins/SharePlugin/SharePlugin.java +++ b/src/org/kde/kdeconnect/Plugins/SharePlugin/SharePlugin.java @@ -1,549 +1,549 @@ /* * 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.DownloadManager; 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.Build; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.provider.MediaStore; import android.support.annotation.WorkerThread; import android.support.v4.app.NotificationCompat; import android.support.v4.content.ContextCompat; import android.support.v4.content.FileProvider; import android.support.v4.provider.DocumentFile; import android.util.Log; import android.widget.Toast; import org.kde.kdeconnect.Helpers.FilesHelper; import org.kde.kdeconnect.Helpers.MediaStoreHelper; import org.kde.kdeconnect.Helpers.NotificationHelper; import org.kde.kdeconnect.NetworkPacket; import org.kde.kdeconnect.Plugins.Plugin; import org.kde.kdeconnect.UserInterface.DeviceSettingsActivity; import org.kde.kdeconnect_tp.R; import java.io.BufferedOutputStream; import java.io.File; import java.io.FileNotFoundException; import java.io.InputStream; import java.net.URL; import java.util.ArrayList; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class SharePlugin extends Plugin implements ReceiveFileRunnable.CallBack { private final static String PACKET_TYPE_SHARE_REQUEST = "kdeconnect.share.request"; private final static boolean openUrlsDirectly = true; private ShareNotification shareNotification; private FinishReceivingRunnable finishReceivingRunnable; private ExecutorService executorService; private ShareInfo currentShareInfo; private Handler handler; public SharePlugin() { executorService = Executors.newSingleThreadExecutor(); handler = new Handler(Looper.getMainLooper()); } @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.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) { if (finishReceivingRunnable != null) { Log.i("SharePlugin", "receiveFile: canceling finishReceivingRunnable"); handler.removeCallbacks(finishReceivingRunnable); finishReceivingRunnable = null; } ShareInfo info = new ShareInfo(); info.currentFileNumber = currentShareInfo == null ? 1 : currentShareInfo.currentFileNumber + 1; - info.inputStream = np.getPayload(); + info.payload = np.getPayload(); info.fileSize = np.getPayloadSize(); info.fileName = np.getString("filename", Long.toString(System.currentTimeMillis())); info.shouldOpen = np.getBoolean("open"); info.setNumberOfFiles(np.getInt("numberOfFiles", 1)); info.setTotalTransferSize(np.getLong("totalPayloadSize", 1)); if (currentShareInfo == null) { currentShareInfo = info; } else { synchronized (currentShareInfo) { currentShareInfo.setNumberOfFiles(info.numberOfFiles()); currentShareInfo.setTotalTransferSize(info.totalTransferSize()); } } String filename = info.fileName; final DocumentFile destinationFolderDocument; //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 (np.getBoolean("open") || !ShareSettingsActivity.isCustomDestinationEnabled(context)) { final String defaultPath = ShareSettingsActivity.getDefaultDestinationDirectory().getAbsolutePath(); filename = FilesHelper.findNonExistingNameForNewFile(defaultPath, filename); destinationFolderDocument = DocumentFile.fromFile(new File(defaultPath)); } else { destinationFolderDocument = ShareSettingsActivity.getDestinationDirectory(context); } String displayName = FilesHelper.getFileNameWithoutExt(filename); String mimeType = FilesHelper.getMimeTypeFromFile(filename); if ("*/*".equals(mimeType)) { displayName = filename; } info.fileDocument = destinationFolderDocument.createFile(mimeType, displayName); assert info.fileDocument != null; if (shareNotification == null) { shareNotification = new ShareNotification(device); } if (info.fileDocument == null) { onError(info, new RuntimeException(context.getString(R.string.cannot_create_file, filename))); return; } shareNotification.setTitle(context.getResources().getQuantityString(R.plurals.incoming_file_title, info.numberOfFiles(), info.numberOfFiles(), device.getName())); shareNotification.show(); if (np.hasPayload()) { try { info.outputStream = new BufferedOutputStream(context.getContentResolver().openOutputStream(info.fileDocument.getUri())); } catch (FileNotFoundException e) { e.printStackTrace(); return; } ReceiveFileRunnable runnable = new ReceiveFileRunnable(info, this); executorService.execute(runnable); } else { onProgress(info, 100); onSuccess(info); } } @Override public void startPreferencesActivity(DeviceSettingsActivity parentActivity) { Intent intent = new Intent(parentActivity, ShareSettingsActivity.class); intent.putExtra("plugin_display_name", getDisplayName()); intent.putExtra("plugin_key", getPluginKey()); parentActivity.startActivity(intent); } 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); 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 e) { } } } - np.setPayload(inputStream, size); + 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}; } @Override public String[] getOutgoingPacketTypes() { return new String[]{PACKET_TYPE_SHARE_REQUEST}; } @Override public String[] getOptionalPermissions() { return new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}; } @Override public void onProgress(ShareInfo info, int progress) { if (progress == 0 && currentShareInfo != info) { currentShareInfo = info; } shareNotification.setProgress(progress, context.getResources().getQuantityString(R.plurals.incoming_files_text, info.numberOfFiles(), info.fileName, info.currentFileNumber, info.numberOfFiles())); shareNotification.show(); } @Override public void onSuccess(ShareInfo info) { Log.i("SharePlugin", "onSuccess() - Transfer finished for file: " + info.fileDocument.getUri().getPath()); if (info.shouldOpen) { shareNotification.cancel(); 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(info.fileDocument.getUri().getPath()); Uri contentUri = FileProvider.getUriForFile(device.getContext(), "org.kde.kdeconnect_tp.fileprovider", file); intent.setDataAndType(contentUri, info.fileDocument.getType()); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); } else { intent.setDataAndType(info.fileDocument.getUri(), info.fileDocument.getType()); } context.startActivity(intent); } else { if (!ShareSettingsActivity.isCustomDestinationEnabled(context)) { Log.i("SharePlugin", "Adding to downloads"); DownloadManager manager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); manager.addCompletedDownload(info.fileDocument.getUri().getLastPathSegment(), device.getName(), true, info.fileDocument.getType(), info.fileDocument.getUri().getPath(), info.fileSize, false); } else { //Make sure it is added to the Android Gallery anyway MediaStoreHelper.indexFile(context, info.fileDocument.getUri()); } if (info.numberOfFiles() == 1 || info.currentFileNumber == info.numberOfFiles()) { finishReceivingRunnable = new FinishReceivingRunnable(info); Log.i("SharePlugin", "onSuccess() - scheduling finishReceivingRunnable"); handler.postDelayed(finishReceivingRunnable, 1000); } } } @Override public void onError(ShareInfo info, Throwable error) { Log.e("SharePlugin", "onError: " + error.getMessage()); info.fileDocument.delete(); //TODO: Show error in notification int failedFiles = info.numberOfFiles() - (info.currentFileNumber - 1); shareNotification.setFinished(context.getResources().getQuantityString(R.plurals.received_files_fail_title, failedFiles, failedFiles, info.numberOfFiles(), device.getName())); shareNotification.show(); shareNotification = null; currentShareInfo = null; } private class FinishReceivingRunnable implements Runnable { private final ShareInfo info; private FinishReceivingRunnable(ShareInfo info) { this.info = info; } @Override public void run() { Log.i("SharePlugin", "FinishReceivingRunnable: Finishing up"); if (shareNotification != null) { //Update the notification and allow to open the file from it shareNotification.setFinished(context.getResources().getQuantityString(R.plurals.received_files_title, info.numberOfFiles(), info.numberOfFiles(), device.getName())); if (info.numberOfFiles() == 1) { shareNotification.setURI(info.fileDocument.getUri(), info.fileDocument.getType(), info.fileName); } shareNotification.show(); shareNotification = null; } finishReceivingRunnable = null; currentShareInfo = null; } } }