diff --git a/src/org/kde/kdeconnect/Backends/BaseLink.java b/src/org/kde/kdeconnect/Backends/BaseLink.java index a238a389..bf9507d5 100644 --- a/src/org/kde/kdeconnect/Backends/BaseLink.java +++ b/src/org/kde/kdeconnect/Backends/BaseLink.java @@ -1,92 +1,95 @@ /* * 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; import android.content.Context; import org.kde.kdeconnect.Device; import org.kde.kdeconnect.NetworkPacket; import java.security.PrivateKey; import java.util.ArrayList; +import androidx.annotation.WorkerThread; + public abstract class BaseLink { protected final Context context; public interface PacketReceiver { void onPacketReceived(NetworkPacket np); } private final BaseLinkProvider linkProvider; private final String deviceId; private final ArrayList receivers = new ArrayList<>(); protected PrivateKey privateKey; protected BaseLink(Context context, String deviceId, BaseLinkProvider linkProvider) { this.context = context; this.linkProvider = linkProvider; this.deviceId = deviceId; } /* To be implemented by each link for pairing handlers */ public abstract String getName(); public abstract BasePairingHandler getPairingHandler(Device device, BasePairingHandler.PairingHandlerCallback callback); public String getDeviceId() { return deviceId; } public void setPrivateKey(PrivateKey key) { privateKey = key; } public BaseLinkProvider getLinkProvider() { return linkProvider; } //The daemon will periodically destroy unpaired links if this returns false public boolean linkShouldBeKeptAlive() { return false; } public void addPacketReceiver(PacketReceiver pr) { receivers.add(pr); } public void removePacketReceiver(PacketReceiver pr) { receivers.remove(pr); } //Should be called from a background thread listening to packages protected void packageReceived(NetworkPacket np) { for(PacketReceiver pr : receivers) { pr.onPacketReceived(np); } } public void disconnect() { linkProvider.connectionLost(this); } //TO OVERRIDE, should be sync + @WorkerThread public abstract boolean sendPacket(NetworkPacket np, Device.SendPacketStatusCallback callback); } diff --git a/src/org/kde/kdeconnect/Backends/BluetoothBackend/BluetoothLink.java b/src/org/kde/kdeconnect/Backends/BluetoothBackend/BluetoothLink.java index 2623485d..27e9f3f5 100644 --- a/src/org/kde/kdeconnect/Backends/BluetoothBackend/BluetoothLink.java +++ b/src/org/kde/kdeconnect/Backends/BluetoothBackend/BluetoothLink.java @@ -1,217 +1,220 @@ /* * 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.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothDevice; 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.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.util.UUID; +import androidx.annotation.WorkerThread; + public class BluetoothLink extends BaseLink { private final ConnectionMultiplexer connection; private final InputStream input; private final OutputStream output; private final BluetoothDevice remoteAddress; 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(input, "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); } if (charsRead < 0) { disconnect(); return; } } if (!continueAccepting) break; 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 " + remoteAddress.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.hasPayloadTransferInfo()) { try { UUID transferUuid = UUID.fromString(np.getPayloadTransferInfo().getString("uuid")); InputStream payloadInputStream = connection.getChannelInputStream(transferUuid); np.setPayload(new NetworkPacket.Payload(payloadInputStream, np.getPayloadSize())); } catch (Exception e) { Log.e("BluetoothLink/receiving", "Unable to get payload", e); } } packageReceived(np); } }); public BluetoothLink(Context context, ConnectionMultiplexer connection, InputStream input, OutputStream output, BluetoothDevice remoteAddress, String deviceId, BluetoothLinkProvider linkProvider) { super(context, deviceId, linkProvider); this.connection = connection; this.input = input; this.output = output; this.remoteAddress = remoteAddress; 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 (connection == null) { return; } continueAccepting = false; try { connection.close(); } catch (IOException ignored) { } linkProvider.disconnectedLink(this, getDeviceId(), remoteAddress); } private void sendMessage(NetworkPacket np) throws JSONException, IOException { byte[] message = np.serialize().getBytes(Charset.forName("UTF-8")); Log.i("BluetoothLink", "Beginning to send message"); output.write(message); Log.i("BluetoothLink", "Finished sending message"); } + @WorkerThread @Override public boolean sendPacket(NetworkPacket np, final Device.SendPacketStatusCallback callback) { /*if (!isConnected()) { Log.e("BluetoothLink", "sendPacketEncrypted failed: not connected"); callback.sendFailure(new Exception("Not connected")); return; }*/ try { UUID transferUuid = null; if (np.hasPayload()) { transferUuid = connection.newChannel(); JSONObject payloadTransferInfo = new JSONObject(); payloadTransferInfo.put("uuid", transferUuid.toString()); np.setPayloadTransferInfo(payloadTransferInfo); } sendMessage(np); if (transferUuid != null) { try (OutputStream payloadStream = connection.getChannelOutputStream(transferUuid)) { int BUFFER_LENGTH = 1024; byte[] buffer = new byte[BUFFER_LENGTH]; int bytesRead; long progress = 0; InputStream stream = np.getPayload().getInputStream(); while ((bytesRead = stream.read(buffer)) != -1) { progress += bytesRead; payloadStream.write(buffer, 0, bytesRead); if (np.getPayloadSize() > 0) { callback.onProgressChanged((int) (100 * progress / np.getPayloadSize())); } } payloadStream.flush(); } catch (Exception e) { callback.onFailure(e); return false; } } 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 f4847fa0..c8853de3 100644 --- a/src/org/kde/kdeconnect/Backends/LanBackend/LanLink.java +++ b/src/org/kde/kdeconnect/Backends/LanBackend/LanLink.java @@ -1,265 +1,268 @@ /* * 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.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 javax.net.ssl.SSLSocket; +import androidx.annotation.WorkerThread; + 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 SSLSocket socket = null; private final LinkDisconnectedCallback callback; @Override public void disconnect() { Log.i("LanLink/Disconnect","socket:"+ socket.hashCode()); try { socket.close(); } catch (IOException e) { Log.e("LanLink", "Error", e); } } //Returns the old socket public SSLSocket reset(final SSLSocket newSocket, ConnectionStarted connectionSource) throws IOException { SSLSocket 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, SSLSocket 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 + @WorkerThread @Override public boolean sendPacket(NetworkPacket np, final Device.SendPacketStatusCallback callback) { 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; } //Log.e("LanLink/sendPacket", np.getType()); //Send body of the network package try { OutputStream writer = socket.getOutputStream(); writer.write(np.serialize().getBytes(StringsHelper.UTF8)); writer.flush(); } catch (Exception e) { disconnect(); //main socket is broken, disconnect throw e; } //Send payload if (server != null) { Socket payloadSocket = null; OutputStream outputStream = null; InputStream inputStream; try { //Wait a maximum of 10 seconds for the other end to establish a connection with our socket, close it afterwards server.setSoTimeout(10*1000); payloadSocket = server.accept(); //Convert to SSL if needed payloadSocket = SslHelper.convertToSslSocket(context, payloadSocket, getDeviceId(), true, false); outputStream = payloadSocket.getOutputStream(); inputStream = np.getPayload().getInputStream(); Log.i("KDE/LanLink", "Beginning to send payload"); byte[] buffer = new byte[4096]; int bytesRead; long size = np.getPayloadSize(); long progress = 0; long timeSinceLastUpdate = -1; while (!np.isCanceled() && (bytesRead = inputStream.read(buffer)) != -1) { //Log.e("ok",""+bytesRead); progress += bytesRead; outputStream.write(buffer, 0, bytesRead); if (size > 0) { if (timeSinceLastUpdate + 500 < System.currentTimeMillis()) { //Report progress every half a second long percent = ((100 * progress) / size); callback.onProgressChanged((int) percent); timeSinceLastUpdate = System.currentTimeMillis(); } } } outputStream.flush(); Log.i("KDE/LanLink", "Finished sending payload ("+progress+" bytes written)"); } finally { try { server.close(); } catch (Exception ignored) { } try { payloadSocket.close(); } catch (Exception ignored) { } np.getPayload().close(); try { outputStream.close(); } catch (Exception ignored) { } } } if (!np.isCanceled()) { callback.onSuccess(); } return true; } catch (Exception e) { if (callback != null) { callback.onFailure(e); } return false; } finally { //Make sure we close the payload stream, if any if (np.hasPayload()) { np.getPayload().close(); } } } private void receivedNetworkPacket(NetworkPacket np) { 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)); payloadSocket = SslHelper.convertToSslSocket(context, payloadSocket, getDeviceId(), true, true); np.setPayload(new NetworkPacket.Payload(payloadSocket, np.getPayloadSize())); } catch (Exception e) { try { payloadSocket.close(); } catch(Exception ignored) { } Log.e("KDE/LanLink", "Exception connecting to payload remote socket", e); } } packageReceived(np); } @Override public boolean linkShouldBeKeptAlive() { return true; //FIXME: Current implementation is broken, so for now we will keep links always established //We keep the remotely initiated connections, since the remotes require them if they want to request //pairing to us, or connections that are already paired. //return (connectionSource == ConnectionStarted.Remotely); } } diff --git a/src/org/kde/kdeconnect/Backends/LoopbackBackend/LoopbackLink.java b/src/org/kde/kdeconnect/Backends/LoopbackBackend/LoopbackLink.java index bb8bef66..9ee0a753 100644 --- a/src/org/kde/kdeconnect/Backends/LoopbackBackend/LoopbackLink.java +++ b/src/org/kde/kdeconnect/Backends/LoopbackBackend/LoopbackLink.java @@ -1,59 +1,62 @@ /* * 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 androidx.annotation.WorkerThread; + 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); } + @WorkerThread @Override public boolean sendPacket(NetworkPacket in, Device.SendPacketStatusCallback callback) { packageReceived(in); if (in.hasPayload()) { callback.onProgressChanged(0); in.setPayload(in.getPayload()); callback.onProgressChanged(100); } callback.onSuccess(); return true; } } diff --git a/src/org/kde/kdeconnect/Device.java b/src/org/kde/kdeconnect/Device.java index e9d6fc5b..35cdef56 100644 --- a/src/org/kde/kdeconnect/Device.java +++ b/src/org/kde/kdeconnect/Device.java @@ -1,883 +1,899 @@ /* * 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.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.res.Resources; import android.graphics.drawable.Drawable; import android.os.Build; import android.preference.PreferenceManager; import android.util.Base64; import android.util.Log; import org.kde.kdeconnect.Backends.BaseLink; import org.kde.kdeconnect.Backends.BasePairingHandler; import org.kde.kdeconnect.Helpers.NotificationHelper; import org.kde.kdeconnect.Helpers.SecurityHelpers.SslHelper; import org.kde.kdeconnect.Plugins.Plugin; import org.kde.kdeconnect.Plugins.PluginFactory; import org.kde.kdeconnect.UserInterface.MainActivity; import org.kde.kdeconnect_tp.R; import java.security.KeyFactory; import java.security.PrivateKey; import java.security.cert.Certificate; import java.security.spec.PKCS8EncodedKeySpec; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.Vector; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; +import androidx.annotation.AnyThread; +import androidx.annotation.WorkerThread; import androidx.core.app.NotificationCompat; import androidx.core.content.ContextCompat; public class Device implements BaseLink.PacketReceiver { private final Context context; private final String deviceId; private String name; public Certificate certificate; private int notificationId; private int protocolVersion; private DeviceType deviceType; private PairStatus pairStatus; private final CopyOnWriteArrayList pairingCallback = new CopyOnWriteArrayList<>(); private final Map pairingHandlers = new HashMap<>(); private final CopyOnWriteArrayList links = new CopyOnWriteArrayList<>(); private DevicePacketQueue packetQueue; private List supportedPlugins = new ArrayList<>(); private final ConcurrentHashMap plugins = new ConcurrentHashMap<>(); private final ConcurrentHashMap pluginsWithoutPermissions = new ConcurrentHashMap<>(); private final ConcurrentHashMap pluginsWithoutOptionalPermissions = new ConcurrentHashMap<>(); private Map> pluginsByIncomingInterface = new HashMap<>(); private final SharedPreferences settings; private final CopyOnWriteArrayList pluginsChangedListeners = new CopyOnWriteArrayList<>(); private Set incomingCapabilities = new HashSet<>(); public boolean supportsPacketType(String type) { return incomingCapabilities.contains(type); } public interface PluginsChangedListener { void onPluginsChanged(Device device); } public enum PairStatus { NotPaired, Paired } public enum DeviceType { Phone, Tablet, Computer, Tv; static DeviceType FromString(String s) { if ("tablet".equals(s)) return Tablet; if ("phone".equals(s)) return Phone; if ("tv".equals(s)) return Tv; return Computer; //Default } public String toString() { switch (this) { case Tablet: return "tablet"; case Phone: return "phone"; case Tv: return "tv"; default: return "desktop"; } } } public interface PairingCallback { void incomingRequest(); void pairingSuccessful(); void pairingFailed(String error); void unpaired(); } //Remembered trusted device, we need to wait for a incoming devicelink to communicate Device(Context context, String deviceId) { settings = context.getSharedPreferences(deviceId, Context.MODE_PRIVATE); //Log.e("Device","Constructor A"); this.context = context; this.deviceId = deviceId; this.name = settings.getString("deviceName", context.getString(R.string.unknown_device)); this.pairStatus = PairStatus.Paired; this.protocolVersion = NetworkPacket.ProtocolVersion; //We don't know it yet this.deviceType = DeviceType.FromString(settings.getString("deviceType", "desktop")); //Assume every plugin is supported until addLink is called and we can get the actual list supportedPlugins = new Vector<>(PluginFactory.getAvailablePlugins()); //Do not load plugins yet, the device is not present //reloadPluginsFromSettings(); } //Device known via an incoming connection sent to us via a devicelink, we know everything but we don't trust it yet Device(Context context, NetworkPacket np, BaseLink dl) { //Log.e("Device","Constructor B"); this.context = context; this.deviceId = np.getString("deviceId"); this.name = context.getString(R.string.unknown_device); //We read it in addLink this.pairStatus = PairStatus.NotPaired; this.protocolVersion = 0; this.deviceType = DeviceType.Computer; settings = context.getSharedPreferences(deviceId, Context.MODE_PRIVATE); addLink(np, dl); } public String getName() { return name != null ? name : context.getString(R.string.unknown_device); } public Drawable getIcon() { int drawableId; switch (deviceType) { case Phone: drawableId = R.drawable.ic_device_phone; break; case Tablet: drawableId = R.drawable.ic_device_tablet; break; case Tv: drawableId = R.drawable.ic_device_tv; break; default: drawableId = R.drawable.ic_device_laptop; } return ContextCompat.getDrawable(context, drawableId); } public DeviceType getDeviceType() { return deviceType; } public String getDeviceId() { return deviceId; } public Context getContext() { return context; } //Returns 0 if the version matches, < 0 if it is older or > 0 if it is newer public int compareProtocolVersion() { return protocolVersion - NetworkPacket.ProtocolVersion; } // // Pairing-related functions // public boolean isPaired() { return pairStatus == PairStatus.Paired; } /* Asks all pairing handlers that, is pair requested? */ public boolean isPairRequested() { boolean pairRequested = false; for (BasePairingHandler ph : pairingHandlers.values()) { pairRequested = pairRequested || ph.isPairRequested(); } return pairRequested; } /* Asks all pairing handlers that, is pair requested by peer? */ public boolean isPairRequestedByPeer() { boolean pairRequestedByPeer = false; for (BasePairingHandler ph : pairingHandlers.values()) { pairRequestedByPeer = pairRequestedByPeer || ph.isPairRequestedByPeer(); } return pairRequestedByPeer; } public void addPairingCallback(PairingCallback callback) { pairingCallback.add(callback); } public void removePairingCallback(PairingCallback callback) { pairingCallback.remove(callback); } public void requestPairing() { Resources res = context.getResources(); if (isPaired()) { for (PairingCallback cb : pairingCallback) { cb.pairingFailed(res.getString(R.string.error_already_paired)); } return; } if (!isReachable()) { for (PairingCallback cb : pairingCallback) { cb.pairingFailed(res.getString(R.string.error_not_reachable)); } return; } for (BasePairingHandler ph : pairingHandlers.values()) { ph.requestPairing(); } } public void unpair() { for (BasePairingHandler ph : pairingHandlers.values()) { ph.unpair(); } unpairInternal(); // Even if there are no pairing handlers, unpair } /** * This method does not send an unpair package, instead it unpairs internally by deleting trusted device info. . Likely to be called after sending package from * pairing handler */ private void unpairInternal() { //Log.e("Device","Unpairing (unpairInternal)"); pairStatus = PairStatus.NotPaired; SharedPreferences preferences = context.getSharedPreferences("trusted_devices", Context.MODE_PRIVATE); preferences.edit().remove(deviceId).apply(); SharedPreferences devicePreferences = context.getSharedPreferences(deviceId, Context.MODE_PRIVATE); devicePreferences.edit().clear().apply(); for (PairingCallback cb : pairingCallback) cb.unpaired(); reloadPluginsFromSettings(); } /* This method should be called after pairing is done from pairing handler. Calling this method again should not create any problem as most of the things will get over writter*/ private void pairingDone() { //Log.e("Device", "Storing as trusted, deviceId: "+deviceId); hidePairingNotification(); pairStatus = PairStatus.Paired; //Store as trusted device SharedPreferences preferences = context.getSharedPreferences("trusted_devices", Context.MODE_PRIVATE); preferences.edit().putBoolean(deviceId, true).apply(); SharedPreferences.Editor editor = context.getSharedPreferences(deviceId, Context.MODE_PRIVATE).edit(); editor.putString("deviceName", name); editor.putString("deviceType", deviceType.toString()); editor.apply(); reloadPluginsFromSettings(); for (PairingCallback cb : pairingCallback) { cb.pairingSuccessful(); } } /* This method is called after accepting pair request form GUI */ public void acceptPairing() { Log.i("KDE/Device", "Accepted pair request started by the other device"); for (BasePairingHandler ph : pairingHandlers.values()) { ph.acceptPairing(); } } /* This method is called after rejecting pairing from GUI */ public void rejectPairing() { Log.i("KDE/Device", "Rejected pair request started by the other device"); //Log.e("Device","Unpairing (rejectPairing)"); pairStatus = PairStatus.NotPaired; for (BasePairingHandler ph : pairingHandlers.values()) { ph.rejectPairing(); } for (PairingCallback cb : pairingCallback) { cb.pairingFailed(context.getString(R.string.error_canceled_by_user)); } } // // Notification related methods used during pairing // public int getNotificationId() { return notificationId; } public void displayPairingNotification() { hidePairingNotification(); notificationId = (int) System.currentTimeMillis(); Intent intent = new Intent(getContext(), MainActivity.class); intent.putExtra(MainActivity.EXTRA_DEVICE_ID, getDeviceId()); intent.putExtra(MainActivity.PAIR_REQUEST_STATUS, MainActivity.PAIRING_PENDING); PendingIntent pendingIntent = PendingIntent.getActivity(getContext(), 1, intent, PendingIntent.FLAG_CANCEL_CURRENT); Intent acceptIntent = new Intent(getContext(), MainActivity.class); Intent rejectIntent = new Intent(getContext(), MainActivity.class); acceptIntent.putExtra(MainActivity.EXTRA_DEVICE_ID, getDeviceId()); //acceptIntent.putExtra("notificationId", notificationId); acceptIntent.putExtra(MainActivity.PAIR_REQUEST_STATUS, MainActivity.PAIRING_ACCEPTED); rejectIntent.putExtra(MainActivity.EXTRA_DEVICE_ID, getDeviceId()); //rejectIntent.putExtra("notificationId", notificationId); rejectIntent.putExtra(MainActivity.PAIR_REQUEST_STATUS, MainActivity.PAIRING_REJECTED); PendingIntent acceptedPendingIntent = PendingIntent.getActivity(getContext(), 2, acceptIntent, PendingIntent.FLAG_ONE_SHOT); PendingIntent rejectedPendingIntent = PendingIntent.getActivity(getContext(), 4, rejectIntent, PendingIntent.FLAG_ONE_SHOT); Resources res = getContext().getResources(); final NotificationManager notificationManager = (NotificationManager) getContext().getSystemService(Context.NOTIFICATION_SERVICE); Notification noti = new NotificationCompat.Builder(getContext(), NotificationHelper.Channels.DEFAULT) .setContentTitle(res.getString(R.string.pairing_request_from, getName())) .setContentText(res.getString(R.string.tap_to_answer)) .setContentIntent(pendingIntent) .setTicker(res.getString(R.string.pair_requested)) .setSmallIcon(R.drawable.ic_notification) .addAction(R.drawable.ic_accept_pairing, res.getString(R.string.pairing_accept), acceptedPendingIntent) .addAction(R.drawable.ic_reject_pairing, res.getString(R.string.pairing_reject), rejectedPendingIntent) .setAutoCancel(true) .setDefaults(Notification.DEFAULT_ALL) .build(); NotificationHelper.notifyCompat(notificationManager, notificationId, noti); } public void hidePairingNotification() { final NotificationManager notificationManager = (NotificationManager) getContext().getSystemService(Context.NOTIFICATION_SERVICE); notificationManager.cancel(notificationId); } // // ComputerLink-related functions // public boolean isReachable() { return !links.isEmpty(); } public void addLink(NetworkPacket identityPacket, BaseLink link) { if (links.isEmpty()) { packetQueue = new DevicePacketQueue(this); } //FilesHelper.LogOpenFileCount(); links.add(link); link.addPacketReceiver(this); this.protocolVersion = identityPacket.getInt("protocolVersion"); if (identityPacket.has("deviceName")) { this.name = identityPacket.getString("deviceName", this.name); SharedPreferences.Editor editor = settings.edit(); editor.putString("deviceName", this.name); editor.apply(); } if (identityPacket.has("deviceType")) { this.deviceType = DeviceType.FromString(identityPacket.getString("deviceType", "desktop")); } if (identityPacket.has("certificate")) { String certificateString = identityPacket.getString("certificate"); try { byte[] certificateBytes = Base64.decode(certificateString, 0); certificate = SslHelper.parseCertificate(certificateBytes); Log.i("KDE/Device", "Got certificate "); } catch (Exception e) { Log.e("KDE/Device", "Error getting certificate", e); } } try { SharedPreferences globalSettings = PreferenceManager.getDefaultSharedPreferences(context); byte[] privateKeyBytes = Base64.decode(globalSettings.getString("privateKey", ""), 0); PrivateKey privateKey = KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(privateKeyBytes)); link.setPrivateKey(privateKey); } catch (Exception e) { Log.e("KDE/Device", "Exception reading our own private key", e); //Should not happen } Log.i("KDE/Device", "addLink " + link.getLinkProvider().getName() + " -> " + getName() + " active links: " + links.size()); if (!pairingHandlers.containsKey(link.getName())) { BasePairingHandler.PairingHandlerCallback callback = new BasePairingHandler.PairingHandlerCallback() { @Override public void incomingRequest() { for (PairingCallback cb : pairingCallback) { cb.incomingRequest(); } } @Override public void pairingDone() { Device.this.pairingDone(); } @Override public void pairingFailed(String error) { for (PairingCallback cb : pairingCallback) { cb.pairingFailed(error); } } @Override public void unpaired() { unpairInternal(); } }; pairingHandlers.put(link.getName(), link.getPairingHandler(this, callback)); } Set outgoingCapabilities = identityPacket.getStringSet("outgoingCapabilities", null); Set incomingCapabilities = identityPacket.getStringSet("incomingCapabilities", null); if (incomingCapabilities != null && outgoingCapabilities != null) { supportedPlugins = new Vector<>(PluginFactory.pluginsForCapabilities(incomingCapabilities, outgoingCapabilities)); } else { supportedPlugins = new Vector<>(PluginFactory.getAvailablePlugins()); } this.incomingCapabilities = incomingCapabilities; reloadPluginsFromSettings(); } public void removeLink(BaseLink link) { //FilesHelper.LogOpenFileCount(); /* Remove pairing handler corresponding to that link too if it was the only link*/ boolean linkPresent = false; for (BaseLink bl : links) { if (bl.getName().equals(link.getName())) { linkPresent = true; break; } } if (!linkPresent) { pairingHandlers.remove(link.getName()); } link.removePacketReceiver(this); links.remove(link); Log.i("KDE/Device", "removeLink: " + link.getLinkProvider().getName() + " -> " + getName() + " active links: " + links.size()); if (links.isEmpty()) { reloadPluginsFromSettings(); packetQueue.disconnected(); packetQueue = null; } } @Override public void onPacketReceived(NetworkPacket np) { if (NetworkPacket.PACKET_TYPE_PAIR.equals(np.getType())) { Log.i("KDE/Device", "Pair package"); for (BasePairingHandler ph : pairingHandlers.values()) { try { ph.packageReceived(np); } catch (Exception e) { Log.e("PairingPacketReceived", "Exception", e); } } } else if (isPaired()) { // pluginsByIncomingInterface may not be built yet if(pluginsByIncomingInterface.isEmpty()) { reloadPluginsFromSettings(); } //If capabilities are not supported, iterate all plugins Collection targetPlugins = pluginsByIncomingInterface.get(np.getType()); if (targetPlugins != null && !targetPlugins.isEmpty()) { for (String pluginKey : targetPlugins) { Plugin plugin = plugins.get(pluginKey); try { plugin.onPacketReceived(np); } catch (Exception e) { Log.e("KDE/Device", "Exception in " + plugin.getPluginKey() + "'s onPacketReceived()", e); //try { Log.e("KDE/Device", "NetworkPacket:" + np.serialize()); } catch (Exception _) { } } } } else { Log.w("Device", "Ignoring packet with type " + np.getType() + " because no plugin can handle it"); } } else { //Log.e("KDE/onPacketReceived","Device not paired, will pass package to unpairedPacketListeners"); // If it is pair package, it should be captured by "if" at start // If not and device is paired, it should be captured by isPaired // Else unpair, this handles the situation when one device unpairs, but other dont know like unpairing when wi-fi is off unpair(); //If capabilities are not supported, iterate all plugins Collection targetPlugins = pluginsByIncomingInterface.get(np.getType()); if (targetPlugins != null && !targetPlugins.isEmpty()) { for (String pluginKey : targetPlugins) { Plugin plugin = plugins.get(pluginKey); try { plugin.onUnpairedDevicePacketReceived(np); } catch (Exception e) { Log.e("KDE/Device", "Exception in " + plugin.getDisplayName() + "'s onPacketReceived() in unPairedPacketListeners", e); } } } else { Log.e("Device", "Ignoring packet with type " + np.getType() + " because no plugin can handle it"); } } } public static abstract class SendPacketStatusCallback { public abstract void onSuccess(); public abstract void onFailure(Throwable e); public void onProgressChanged(int percent) { } } private final SendPacketStatusCallback defaultCallback = new SendPacketStatusCallback() { @Override public void onSuccess() { } @Override public void onFailure(Throwable e) { Log.e("KDE/sendPacket", "Exception", e); } }; + @AnyThread public void sendPacket(NetworkPacket np) { sendPacket(np, -1, defaultCallback); } + @AnyThread public void sendPacket(NetworkPacket np, int replaceID) { sendPacket(np, replaceID, defaultCallback); } + @WorkerThread public boolean sendPacketBlocking(NetworkPacket np) { return sendPacketBlocking(np, defaultCallback); } + @AnyThread public void sendPacket(final NetworkPacket np, final SendPacketStatusCallback callback) { sendPacket(np, -1, callback); } /** * Send a packet to the device asynchronously * @param np The packet * @param replaceID If positive, replaces all unsent packages with the same replaceID * @param callback A callback for success/failure */ + @AnyThread public void sendPacket(final NetworkPacket np, int replaceID, final SendPacketStatusCallback callback) { if (packetQueue == null) { callback.onFailure(new Exception("Device disconnected!")); } else { packetQueue.addPacket(np, replaceID, callback); } } /** * Check if we still have an unsent packet in the queue with the given ID. * If so, remove it from the queue and return it * @param replaceID The replace ID (must be positive) * @return The found packet, or null */ public NetworkPacket getAndRemoveUnsentPacket(int replaceID) { if (packetQueue == null) { return null; } else { return packetQueue.getAndRemoveUnsentPacket(replaceID); } } + /** + * Send {@code np} over one of this device's connected {@link #links}. + * + * @param np the packet to send + * @param callback a callback that can receive realtime updates + * @return true if the packet was sent ok, false otherwise + * @see BaseLink#sendPacket(NetworkPacket, SendPacketStatusCallback) + */ + @WorkerThread public boolean sendPacketBlocking(final NetworkPacket np, final SendPacketStatusCallback callback) { /* if (!m_outgoingCapabilities.contains(np.getType()) && !NetworkPacket.protocolPacketTypes.contains(np.getType())) { Log.e("Device/sendPacket", "Plugin tried to send an undeclared package: " + np.getType()); Log.w("Device/sendPacket", "Declared outgoing package types: " + Arrays.toString(m_outgoingCapabilities.toArray())); } */ boolean success = false; //Make a copy to avoid concurrent modification exception if the original list changes for (final BaseLink link : links) { if (link == null) continue; //Since we made a copy, maybe somebody destroyed the link in the meanwhile success = link.sendPacket(np, callback); if (success) break; //If the link didn't call sendSuccess(), try the next one } if (!success) { Log.e("KDE/sendPacket", "No device link (of " + links.size() + " available) could send the package. Packet " + np.getType() + " to " + name + " lost!"); } return success; } // // Plugin-related functions // public T getPlugin(Class pluginClass) { Plugin plugin = getPlugin(Plugin.getPluginKey(pluginClass)); return (T) plugin; } public Plugin getPlugin(String pluginKey) { return plugins.get(pluginKey); } private synchronized boolean addPlugin(final String pluginKey) { Plugin existing = plugins.get(pluginKey); if (existing != null) { if (existing.getMinSdk() > Build.VERSION.SDK_INT) { Log.i("KDE/addPlugin", "Min API level not fulfilled " + pluginKey); return false; } //Log.w("KDE/addPlugin","plugin already present:" + pluginKey); if (existing.checkOptionalPermissions()) { Log.i("KDE/addPlugin", "Optional Permissions OK " + pluginKey); pluginsWithoutOptionalPermissions.remove(pluginKey); } else { Log.e("KDE/addPlugin", "No optional permission " + pluginKey); pluginsWithoutOptionalPermissions.put(pluginKey, existing); } return true; } final Plugin plugin = PluginFactory.instantiatePluginForDevice(context, pluginKey, this); if (plugin == null) { Log.e("KDE/addPlugin", "could not instantiate plugin: " + pluginKey); return false; } if (plugin.getMinSdk() > Build.VERSION.SDK_INT) { Log.i("KDE/addPlugin", "Min API level not fulfilled" + pluginKey); return false; } boolean success; try { success = plugin.onCreate(); } catch (Exception e) { success = false; Log.e("KDE/addPlugin", "plugin failed to load " + pluginKey, e); } plugins.put(pluginKey, plugin); if (!plugin.checkRequiredPermissions()) { Log.e("KDE/addPlugin", "No permission " + pluginKey); plugins.remove(pluginKey); pluginsWithoutPermissions.put(pluginKey, plugin); success = false; } else { Log.i("KDE/addPlugin", "Permissions OK " + pluginKey); pluginsWithoutPermissions.remove(pluginKey); if (plugin.checkOptionalPermissions()) { Log.i("KDE/addPlugin", "Optional Permissions OK " + pluginKey); pluginsWithoutOptionalPermissions.remove(pluginKey); } else { Log.e("KDE/addPlugin", "No optional permission " + pluginKey); pluginsWithoutOptionalPermissions.put(pluginKey, plugin); } } return success; } private synchronized boolean removePlugin(String pluginKey) { Plugin plugin = plugins.remove(pluginKey); if (plugin == null) { return false; } try { plugin.onDestroy(); //Log.e("removePlugin","removed " + pluginKey); } catch (Exception e) { Log.e("KDE/removePlugin", "Exception calling onDestroy for plugin " + pluginKey, e); } return true; } public void setPluginEnabled(String pluginKey, boolean value) { settings.edit().putBoolean(pluginKey, value).apply(); reloadPluginsFromSettings(); } public boolean isPluginEnabled(String pluginKey) { boolean enabledByDefault = PluginFactory.getPluginInfo(pluginKey).isEnabledByDefault(); return settings.getBoolean(pluginKey, enabledByDefault); } public void reloadPluginsFromSettings() { HashMap> newPluginsByIncomingInterface = new HashMap<>(); for (String pluginKey : supportedPlugins) { PluginFactory.PluginInfo pluginInfo = PluginFactory.getPluginInfo(pluginKey); boolean pluginEnabled = false; boolean listenToUnpaired = pluginInfo.listenToUnpaired(); if ((isPaired() || listenToUnpaired) && isReachable()) { pluginEnabled = isPluginEnabled(pluginKey); } if (pluginEnabled) { boolean success = addPlugin(pluginKey); if (success) { for (String packageType : pluginInfo.getSupportedPacketTypes()) { ArrayList plugins = newPluginsByIncomingInterface.get(packageType); if (plugins == null) plugins = new ArrayList<>(); plugins.add(pluginKey); newPluginsByIncomingInterface.put(packageType, plugins); } } } else { removePlugin(pluginKey); } } pluginsByIncomingInterface = newPluginsByIncomingInterface; onPluginsChanged(); } public void onPluginsChanged() { for (PluginsChangedListener listener : pluginsChangedListeners) { listener.onPluginsChanged(Device.this); } } public ConcurrentHashMap getLoadedPlugins() { return plugins; } public ConcurrentHashMap getPluginsWithoutPermissions() { return pluginsWithoutPermissions; } public ConcurrentHashMap getPluginsWithoutOptionalPermissions() { return pluginsWithoutOptionalPermissions; } public void addPluginsChangedListener(PluginsChangedListener listener) { pluginsChangedListeners.add(listener); } public void removePluginsChangedListener(PluginsChangedListener listener) { pluginsChangedListeners.remove(listener); } public void disconnect() { for (BaseLink link : links) { link.disconnect(); } } public boolean deviceShouldBeKeptAlive() { SharedPreferences preferences = context.getSharedPreferences("trusted_devices", Context.MODE_PRIVATE); if (preferences.contains(getDeviceId())) { //Log.e("DeviceShouldBeKeptAlive", "because it's a paired device"); return true; //Already paired } for (BaseLink l : links) { if (l.linkShouldBeKeptAlive()) { return true; } } return false; } public List getSupportedPlugins() { return supportedPlugins; } } diff --git a/src/org/kde/kdeconnect/Plugins/SharePlugin/CompositeReceiveFileJob.java b/src/org/kde/kdeconnect/Plugins/SharePlugin/CompositeReceiveFileJob.java index 63a67318..1a63081e 100644 --- a/src/org/kde/kdeconnect/Plugins/SharePlugin/CompositeReceiveFileJob.java +++ b/src/org/kde/kdeconnect/Plugins/SharePlugin/CompositeReceiveFileJob.java @@ -1,336 +1,357 @@ /* * Copyright 2018 Erik Duisters * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License or (at your option) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package org.kde.kdeconnect.Plugins.SharePlugin; import android.app.DownloadManager; import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; import android.net.Uri; import android.os.Build; import android.util.Log; import org.kde.kdeconnect.Device; import org.kde.kdeconnect.Helpers.FilesHelper; import org.kde.kdeconnect.Helpers.MediaStoreHelper; import org.kde.kdeconnect.NetworkPacket; import org.kde.kdeconnect.async.BackgroundJob; import org.kde.kdeconnect_tp.R; import java.io.BufferedOutputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.util.ArrayList; import java.util.List; +import androidx.annotation.GuardedBy; import androidx.core.content.FileProvider; import androidx.documentfile.provider.DocumentFile; +/** + * A type of {@link BackgroundJob} that reads Files from another device. + * + *

+ * We receive the requests as {@link NetworkPacket}s. + *

+ *

+ * Each packet should have a 'filename' property and a payload. If the payload is missing, + * we'll just create an empty file. You can add new packets anytime via + * {@link #addNetworkPacket(NetworkPacket)}. + *

+ *

+ * The I/O-part of this file reading is handled by {@link #receiveFile(InputStream, OutputStream)}. + *

+ * + * @see CompositeUploadFileJob + */ public class CompositeReceiveFileJob extends BackgroundJob { private final ReceiveNotification receiveNotification; private NetworkPacket currentNetworkPacket; private String currentFileName; private int currentFileNum; private long totalReceived; private long lastProgressTimeMillis; private long prevProgressPercentage; private final Object lock; //Use to protect concurrent access to the variables below + @GuardedBy("lock") private final List networkPacketList; + @GuardedBy("lock") private int totalNumFiles; + @GuardedBy("lock") private long totalPayloadSize; private boolean isRunning; CompositeReceiveFileJob(Device device, BackgroundJob.Callback callBack) { super(device, callBack); lock = new Object(); networkPacketList = new ArrayList<>(); receiveNotification = new ReceiveNotification(device, getId()); currentFileNum = 0; totalNumFiles = 0; totalPayloadSize = 0; totalReceived = 0; lastProgressTimeMillis = 0; prevProgressPercentage = 0; } private Device getDevice() { return requestInfo; } boolean isRunning() { return isRunning; } void updateTotals(int numberOfFiles, long totalPayloadSize) { synchronized (lock) { this.totalNumFiles = numberOfFiles; this.totalPayloadSize = totalPayloadSize; receiveNotification.setTitle(getDevice().getContext().getResources() .getQuantityString(R.plurals.incoming_file_title, totalNumFiles, totalNumFiles, getDevice().getName())); } } void addNetworkPacket(NetworkPacket networkPacket) { synchronized (lock) { if (!networkPacketList.contains(networkPacket)) { networkPacketList.add(networkPacket); totalNumFiles = networkPacket.getInt(SharePlugin.KEY_NUMBER_OF_FILES, 1); totalPayloadSize = networkPacket.getLong(SharePlugin.KEY_TOTAL_PAYLOAD_SIZE); receiveNotification.setTitle(getDevice().getContext().getResources() .getQuantityString(R.plurals.incoming_file_title, totalNumFiles, totalNumFiles, getDevice().getName())); } } } @Override public void run() { boolean done; OutputStream outputStream = null; synchronized (lock) { done = networkPacketList.isEmpty(); } try { DocumentFile fileDocument = null; isRunning = true; while (!done && !canceled) { synchronized (lock) { currentNetworkPacket = networkPacketList.get(0); } currentFileName = currentNetworkPacket.getString("filename", Long.toString(System.currentTimeMillis())); currentFileNum++; setProgress((int)prevProgressPercentage); fileDocument = getDocumentFileFor(currentFileName, currentNetworkPacket.getBoolean("open")); if (currentNetworkPacket.hasPayload()) { outputStream = new BufferedOutputStream(getDevice().getContext().getContentResolver().openOutputStream(fileDocument.getUri())); InputStream inputStream = currentNetworkPacket.getPayload().getInputStream(); long received = receiveFile(inputStream, outputStream); currentNetworkPacket.getPayload().close(); if ( received != currentNetworkPacket.getPayloadSize()) { fileDocument.delete(); if (!canceled) { throw new RuntimeException("Failed to receive: " + currentFileName + " received:" + received + " bytes, expected: " + currentNetworkPacket.getPayloadSize() + " bytes"); } } else { publishFile(fileDocument, received); } } else { //TODO: Only set progress to 100 if this is the only file/packet to send setProgress(100); publishFile(fileDocument, 0); } boolean listIsEmpty; synchronized (lock) { networkPacketList.remove(0); listIsEmpty = networkPacketList.isEmpty(); } if (listIsEmpty && !canceled) { try { Thread.sleep(1000); } catch (InterruptedException ignored) {} synchronized (lock) { if (currentFileNum < totalNumFiles && networkPacketList.isEmpty()) { throw new RuntimeException("Failed to receive " + (totalNumFiles - currentFileNum + 1) + " files"); } } } synchronized (lock) { done = networkPacketList.isEmpty(); } } isRunning = false; if (canceled) { receiveNotification.cancel(); return; } int numFiles; synchronized (lock) { numFiles = totalNumFiles; } if (numFiles == 1 && currentNetworkPacket.getBoolean("open", false)) { receiveNotification.cancel(); openFile(fileDocument); } else { //Update the notification and allow to open the file from it receiveNotification.setFinished(getDevice().getContext().getResources().getQuantityString(R.plurals.received_files_title, numFiles, getDevice().getName(), numFiles)); if (totalNumFiles == 1 && fileDocument != null) { receiveNotification.setURI(fileDocument.getUri(), fileDocument.getType(), fileDocument.getName()); } receiveNotification.show(); } reportResult(null); } catch (ActivityNotFoundException e) { receiveNotification.setFinished(getDevice().getContext().getString(R.string.no_app_for_opening)); receiveNotification.show(); } catch (Exception e) { isRunning = false; Log.e("Shareplugin", "Error receiving file", e); int failedFiles; synchronized (lock) { failedFiles = (totalNumFiles - currentFileNum + 1); } receiveNotification.setFinished(getDevice().getContext().getResources().getQuantityString(R.plurals.received_files_fail_title, failedFiles, getDevice().getName(), failedFiles, totalNumFiles)); receiveNotification.show(); reportError(e); } finally { closeAllInputStreams(); networkPacketList.clear(); if (outputStream != null) { try { outputStream.close(); } catch (IOException ignored) {} } } } private DocumentFile getDocumentFileFor(final String filename, final boolean open) throws RuntimeException { final DocumentFile destinationFolderDocument; String filenameToUse = filename; //We need to check for already existing files only when storing in the default path. //User-defined paths use the new Storage Access Framework that already handles this. //If the file should be opened immediately store it in the standard location to avoid the FileProvider trouble (See ReceiveNotification::setURI) if (open || !ShareSettingsFragment.isCustomDestinationEnabled(getDevice().getContext())) { final String defaultPath = ShareSettingsFragment.getDefaultDestinationDirectory().getAbsolutePath(); filenameToUse = FilesHelper.findNonExistingNameForNewFile(defaultPath, filenameToUse); destinationFolderDocument = DocumentFile.fromFile(new File(defaultPath)); } else { destinationFolderDocument = ShareSettingsFragment.getDestinationDirectory(getDevice().getContext()); } String displayName = FilesHelper.getFileNameWithoutExt(filenameToUse); String mimeType = FilesHelper.getMimeTypeFromFile(filenameToUse); if ("*/*".equals(mimeType)) { displayName = filenameToUse; } DocumentFile fileDocument = destinationFolderDocument.createFile(mimeType, displayName); if (fileDocument == null) { throw new RuntimeException(getDevice().getContext().getString(R.string.cannot_create_file, filenameToUse)); } return fileDocument; } private long receiveFile(InputStream input, OutputStream output) throws IOException { byte[] data = new byte[4096]; int count; long received = 0; while ((count = input.read(data)) >= 0 && !canceled) { received += count; totalReceived += count; output.write(data, 0, count); long progressPercentage; synchronized (lock) { progressPercentage = (totalReceived * 100 / totalPayloadSize); } long curTimeMillis = System.currentTimeMillis(); if (progressPercentage != prevProgressPercentage && (progressPercentage == 100 || curTimeMillis - lastProgressTimeMillis >= 500)) { prevProgressPercentage = progressPercentage; lastProgressTimeMillis = curTimeMillis; setProgress((int)progressPercentage); } } output.flush(); return received; } private void closeAllInputStreams() { for (NetworkPacket np : networkPacketList) { np.getPayload().close(); } } private void setProgress(int progress) { synchronized (lock) { receiveNotification.setProgress(progress, getDevice().getContext().getResources() .getQuantityString(R.plurals.incoming_files_text, totalNumFiles, currentFileName, currentFileNum, totalNumFiles)); } receiveNotification.show(); } private void publishFile(DocumentFile fileDocument, long size) { if (!ShareSettingsFragment.isCustomDestinationEnabled(getDevice().getContext())) { Log.i("SharePlugin", "Adding to downloads"); DownloadManager manager = (DownloadManager) getDevice().getContext().getSystemService(Context.DOWNLOAD_SERVICE); manager.addCompletedDownload(fileDocument.getUri().getLastPathSegment(), getDevice().getName(), true, fileDocument.getType(), fileDocument.getUri().getPath(), size, false); } else { //Make sure it is added to the Android Gallery anyway Log.i("SharePlugin", "Adding to gallery"); MediaStoreHelper.indexFile(getDevice().getContext(), fileDocument.getUri()); } } private void openFile(DocumentFile fileDocument) { String mimeType = FilesHelper.getMimeTypeFromFile(fileDocument.getName()); Intent intent = new Intent(Intent.ACTION_VIEW); if (Build.VERSION.SDK_INT >= 24) { //Nougat and later require "content://" uris instead of "file://" uris File file = new File(fileDocument.getUri().getPath()); Uri contentUri = FileProvider.getUriForFile(getDevice().getContext(), "org.kde.kdeconnect_tp.fileprovider", file); intent.setDataAndType(contentUri, mimeType); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK); } else { intent.setDataAndType(fileDocument.getUri(), mimeType); } getDevice().getContext().startActivity(intent); } } diff --git a/src/org/kde/kdeconnect/Plugins/SharePlugin/CompositeUploadFileJob.java b/src/org/kde/kdeconnect/Plugins/SharePlugin/CompositeUploadFileJob.java index a9e22827..69f6a828 100644 --- a/src/org/kde/kdeconnect/Plugins/SharePlugin/CompositeUploadFileJob.java +++ b/src/org/kde/kdeconnect/Plugins/SharePlugin/CompositeUploadFileJob.java @@ -1,221 +1,247 @@ /* * Copyright 2019 Erik Duisters * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License or (at your option) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package org.kde.kdeconnect.Plugins.SharePlugin; import android.os.Handler; import android.os.Looper; import org.kde.kdeconnect.Device; import org.kde.kdeconnect.NetworkPacket; import org.kde.kdeconnect.async.BackgroundJob; import org.kde.kdeconnect_tp.R; import java.util.ArrayList; import java.util.List; +import androidx.annotation.GuardedBy; import androidx.annotation.NonNull; +/** + * A type of {@link BackgroundJob} that sends Files to another device. + * + *

+ * We represent the individual upload requests as {@link NetworkPacket}s. + *

+ *

+ * Each packet should have a 'filename' property and a payload. If the payload is + * missing, we'll just send an empty file. You can add new packets anytime via + * {@link #addNetworkPacket(NetworkPacket)}. + *

+ *

+ * The I/O-part of this file sending is handled by + * {@link Device#sendPacketBlocking(NetworkPacket, Device.SendPacketStatusCallback)}. + *

+ * + * @see CompositeReceiveFileJob + * @see SendPacketStatusCallback + */ public class CompositeUploadFileJob extends BackgroundJob { private boolean isRunning; private Handler handler; private String currentFileName; private int currentFileNum; private boolean updatePacketPending; private long totalSend; private int prevProgressPercentage; private UploadNotification uploadNotification; private final Object lock; //Use to protect concurrent access to the variables below + @GuardedBy("lock") private final List networkPacketList; private NetworkPacket currentNetworkPacket; private final Device.SendPacketStatusCallback sendPacketStatusCallback; + @GuardedBy("lock") private int totalNumFiles; + @GuardedBy("lock") private long totalPayloadSize; CompositeUploadFileJob(@NonNull Device device, @NonNull Callback callback) { super(device, callback); isRunning = false; handler = new Handler(Looper.getMainLooper()); currentFileNum = 0; currentFileName = ""; updatePacketPending = false; lock = new Object(); networkPacketList = new ArrayList<>(); totalNumFiles = 0; totalPayloadSize = 0; totalSend = 0; prevProgressPercentage = 0; uploadNotification = new UploadNotification(getDevice(), getId()); sendPacketStatusCallback = new SendPacketStatusCallback(); } private Device getDevice() { return requestInfo; } @Override public void run() { boolean done; isRunning = true; synchronized (lock) { done = networkPacketList.isEmpty(); } try { while (!done && !canceled) { synchronized (lock) { currentNetworkPacket = networkPacketList.remove(0); } currentFileName = currentNetworkPacket.getString("filename"); currentFileNum++; setProgress(prevProgressPercentage); addTotalsToNetworkPacket(currentNetworkPacket); if (!getDevice().sendPacketBlocking(currentNetworkPacket, sendPacketStatusCallback)) { throw new RuntimeException("Sending packet failed"); } synchronized (lock) { done = networkPacketList.isEmpty(); } } if (canceled) { uploadNotification.cancel(); } else { uploadNotification.setFinished(getDevice().getContext().getResources().getQuantityString(R.plurals.sent_files_title, currentFileNum, getDevice().getName(), currentFileNum)); uploadNotification.show(); reportResult(null); } } catch (RuntimeException e) { int failedFiles; synchronized (lock) { failedFiles = (totalNumFiles - currentFileNum + 1); uploadNotification.setFinished(getDevice().getContext().getResources() .getQuantityString(R.plurals.send_files_fail_title, failedFiles, getDevice().getName(), failedFiles, totalNumFiles)); } uploadNotification.show(); reportError(e); } finally { isRunning = false; for (NetworkPacket networkPacket : networkPacketList) { networkPacket.getPayload().close(); } networkPacketList.clear(); } } private void addTotalsToNetworkPacket(NetworkPacket networkPacket) { synchronized (lock) { networkPacket.set(SharePlugin.KEY_NUMBER_OF_FILES, totalNumFiles); networkPacket.set(SharePlugin.KEY_TOTAL_PAYLOAD_SIZE, totalPayloadSize); } } private void setProgress(int progress) { synchronized (lock) { uploadNotification.setProgress(progress, getDevice().getContext().getResources() .getQuantityString(R.plurals.outgoing_files_text, totalNumFiles, currentFileName, currentFileNum, totalNumFiles)); } uploadNotification.show(); } void addNetworkPacket(@NonNull NetworkPacket networkPacket) { synchronized (lock) { networkPacketList.add(networkPacket); totalNumFiles++; if (networkPacket.getPayloadSize() >= 0) { totalPayloadSize += networkPacket.getPayloadSize(); } uploadNotification.setTitle(getDevice().getContext().getResources() .getQuantityString(R.plurals.outgoing_file_title, totalNumFiles, totalNumFiles, getDevice().getName())); //Give SharePlugin some time to add more NetworkPackets if (isRunning && !updatePacketPending) { updatePacketPending = true; handler.post(this::sendUpdatePacket); } } } + /** + * Use this to send metadata ahead of all the other {@link #networkPacketList packets}. + */ private void sendUpdatePacket() { NetworkPacket np = new NetworkPacket(SharePlugin.PACKET_TYPE_SHARE_REQUEST_UPDATE); synchronized (lock) { np.set("numberOfFiles", totalNumFiles); np.set("totalPayloadSize", totalPayloadSize); updatePacketPending = false; } getDevice().sendPacket(np); } @Override public void cancel() { super.cancel(); currentNetworkPacket.cancel(); } private class SendPacketStatusCallback extends Device.SendPacketStatusCallback { @Override public void onProgressChanged(int percent) { float send = totalSend + (currentNetworkPacket.getPayloadSize() * ((float)percent / 100)); int progress = (int)((send * 100) / totalPayloadSize); if (progress != prevProgressPercentage) { setProgress(progress); prevProgressPercentage = progress; } } @Override public void onSuccess() { if (currentNetworkPacket.getPayloadSize() == 0) { synchronized (lock) { if (networkPacketList.isEmpty()) { setProgress(100); } } } totalSend += currentNetworkPacket.getPayloadSize(); } @Override public void onFailure(Throwable e) { //Ignored } } } diff --git a/src/org/kde/kdeconnect/Plugins/SharePlugin/SharePlugin.java b/src/org/kde/kdeconnect/Plugins/SharePlugin/SharePlugin.java index fac25b1c..2d3f99a3 100644 --- a/src/org/kde/kdeconnect/Plugins/SharePlugin/SharePlugin.java +++ b/src/org/kde/kdeconnect/Plugins/SharePlugin/SharePlugin.java @@ -1,335 +1,342 @@ /* * Copyright 2014 Albert Vaca Cintora * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License or (at your option) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package org.kde.kdeconnect.Plugins.SharePlugin; import android.Manifest; import android.app.Activity; import android.content.ClipboardManager; import android.content.Context; import android.content.Intent; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.util.Log; import android.widget.Toast; import org.kde.kdeconnect.Helpers.FilesHelper; import org.kde.kdeconnect.NetworkPacket; import org.kde.kdeconnect.Plugins.Plugin; import org.kde.kdeconnect.Plugins.PluginFactory; import org.kde.kdeconnect.UserInterface.PluginSettingsFragment; import org.kde.kdeconnect.async.BackgroundJob; import org.kde.kdeconnect.async.BackgroundJobHandler; import org.kde.kdeconnect_tp.R; import java.net.URL; import java.util.ArrayList; import androidx.annotation.NonNull; import androidx.annotation.WorkerThread; import androidx.core.content.ContextCompat; +/** + * A Plugin for sharing and receiving files and uris. + *

+ * All of the associated I/O work is scheduled on background + * threads by {@link BackgroundJobHandler}. + *

+ */ @PluginFactory.LoadablePlugin public class SharePlugin extends Plugin { final static String ACTION_CANCEL_SHARE = "org.kde.kdeconnect.Plugins.SharePlugin.CancelShare"; final static String CANCEL_SHARE_DEVICE_ID_EXTRA = "deviceId"; final static String CANCEL_SHARE_BACKGROUND_JOB_ID_EXTRA = "backgroundJobId"; private final static String PACKET_TYPE_SHARE_REQUEST = "kdeconnect.share.request"; final static String PACKET_TYPE_SHARE_REQUEST_UPDATE = "kdeconnect.share.request.update"; final static String KEY_NUMBER_OF_FILES = "numberOfFiles"; final static String KEY_TOTAL_PAYLOAD_SIZE = "totalPayloadSize"; private BackgroundJobHandler backgroundJobHandler; private final Handler handler; private CompositeReceiveFileJob receiveFileJob; private CompositeUploadFileJob uploadFileJob; private final Callback receiveFileJobCallback; public SharePlugin() { backgroundJobHandler = BackgroundJobHandler.newFixedThreadPoolBackgroundJobHander(5); handler = new Handler(Looper.getMainLooper()); receiveFileJobCallback = new Callback(); } @Override public boolean onCreate() { optionalPermissionExplanation = R.string.share_optional_permission_explanation; return true; } @Override public String getDisplayName() { return context.getResources().getString(R.string.pref_plugin_sharereceiver); } @Override public Drawable getIcon() { return ContextCompat.getDrawable(context, R.drawable.share_plugin_action); } @Override public String getDescription() { return context.getResources().getString(R.string.pref_plugin_sharereceiver_desc); } @Override public boolean hasMainActivity() { return true; } @Override public String getActionName() { return context.getString(R.string.send_files); } @Override public void startMainActivity(Activity parentActivity) { Intent intent = new Intent(parentActivity, SendFileActivity.class); intent.putExtra("deviceId", device.getDeviceId()); parentActivity.startActivity(intent); } @Override public boolean hasSettings() { return true; } @Override @WorkerThread public boolean onPacketReceived(NetworkPacket np) { try { if (np.getType().equals(PACKET_TYPE_SHARE_REQUEST_UPDATE)) { if (receiveFileJob != null && receiveFileJob.isRunning()) { receiveFileJob.updateTotals(np.getInt(KEY_NUMBER_OF_FILES), np.getLong(KEY_TOTAL_PAYLOAD_SIZE)); } else { Log.d("SharePlugin", "Received update packet but CompositeUploadJob is null or not running"); } return true; } if (np.has("filename")) { if (isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { receiveFile(np); } else { Log.i("SharePlugin", "no Permission for Storage"); } } else if (np.has("text")) { Log.i("SharePlugin", "hasText"); receiveText(np); } else if (np.has("url")) { receiveUrl(np); } else { Log.e("SharePlugin", "Error: Nothing attached!"); } } catch (Exception e) { Log.e("SharePlugin", "Exception"); e.printStackTrace(); } return true; } private void receiveUrl(NetworkPacket np) { String url = np.getString("url"); Log.i("SharePlugin", "hasUrl: " + url); Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(browserIntent); } private void receiveText(NetworkPacket np) { String text = np.getString("text"); ClipboardManager cm = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); cm.setText(text); handler.post(() -> Toast.makeText(context, R.string.shareplugin_text_saved, Toast.LENGTH_LONG).show()); } @WorkerThread private void receiveFile(NetworkPacket np) { CompositeReceiveFileJob job; boolean hasNumberOfFiles = np.has(KEY_NUMBER_OF_FILES); boolean hasOpen = np.has("open"); if (hasNumberOfFiles && !hasOpen && receiveFileJob != null) { job = receiveFileJob; } else { job = new CompositeReceiveFileJob(device, receiveFileJobCallback); } if (!hasNumberOfFiles) { np.set(KEY_NUMBER_OF_FILES, 1); np.set(KEY_TOTAL_PAYLOAD_SIZE, np.getPayloadSize()); } job.addNetworkPacket(np); if (job != receiveFileJob) { if (hasNumberOfFiles && !hasOpen) { receiveFileJob = job; } backgroundJobHandler.runJob(job); } } @Override public PluginSettingsFragment getSettingsFragment(Activity activity) { return ShareSettingsFragment.newInstance(getPluginKey()); } void sendUriList(final ArrayList uriList) { CompositeUploadFileJob job; if (uploadFileJob == null) { job = new CompositeUploadFileJob(device, this.receiveFileJobCallback); } else { job = uploadFileJob; } //Read all the data early, as we only have permissions to do it while the activity is alive for (Uri uri : uriList) { NetworkPacket np = FilesHelper.uriToNetworkPacket(context, uri, PACKET_TYPE_SHARE_REQUEST); if (np != null) { job.addNetworkPacket(np); } } if (job != uploadFileJob) { uploadFileJob = job; backgroundJobHandler.runJob(uploadFileJob); } } public void share(Intent intent) { Bundle extras = intent.getExtras(); if (extras != null) { if (extras.containsKey(Intent.EXTRA_STREAM)) { try { ArrayList uriList; if (!Intent.ACTION_SEND.equals(intent.getAction())) { uriList = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); } else { Uri uri = extras.getParcelable(Intent.EXTRA_STREAM); uriList = new ArrayList<>(); uriList.add(uri); } sendUriList(uriList); } catch (Exception e) { Log.e("ShareActivity", "Exception"); e.printStackTrace(); } } else if (extras.containsKey(Intent.EXTRA_TEXT)) { String text = extras.getString(Intent.EXTRA_TEXT); String subject = extras.getString(Intent.EXTRA_SUBJECT); //Hack: Detect shared youtube videos, so we can open them in the browser instead of as text if (subject != null && subject.endsWith("YouTube")) { int index = text.indexOf(": http://youtu.be/"); if (index > 0) { text = text.substring(index + 2); //Skip ": " } } boolean isUrl; try { new URL(text); isUrl = true; } catch (Exception e) { isUrl = false; } NetworkPacket np = new NetworkPacket(SharePlugin.PACKET_TYPE_SHARE_REQUEST); if (isUrl) { np.set("url", text); } else { np.set("text", text); } device.sendPacket(np); } } } @Override public String[] getSupportedPacketTypes() { return new String[]{PACKET_TYPE_SHARE_REQUEST, PACKET_TYPE_SHARE_REQUEST_UPDATE}; } @Override public String[] getOutgoingPacketTypes() { return new String[]{PACKET_TYPE_SHARE_REQUEST}; } @Override public String[] getOptionalPermissions() { return new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}; } private class Callback implements BackgroundJob.Callback { @Override public void onResult(@NonNull BackgroundJob job, Void result) { if (job == receiveFileJob) { receiveFileJob = null; } else if (job == uploadFileJob) { uploadFileJob = null; } } @Override public void onError(@NonNull BackgroundJob job, @NonNull Throwable error) { if (job == receiveFileJob) { receiveFileJob = null; } else if (job == uploadFileJob) { uploadFileJob = null; } } } void cancelJob(long jobId) { if (backgroundJobHandler.isRunning(jobId)) { BackgroundJob job = backgroundJobHandler.getJob(jobId); if (job != null) { job.cancel(); if (job == receiveFileJob) { receiveFileJob = null; } else if (job == uploadFileJob) { uploadFileJob = null; } } } } } diff --git a/src/org/kde/kdeconnect/async/BackgroundJobHandler.java b/src/org/kde/kdeconnect/async/BackgroundJobHandler.java index b2891833..ad4172e7 100644 --- a/src/org/kde/kdeconnect/async/BackgroundJobHandler.java +++ b/src/org/kde/kdeconnect/async/BackgroundJobHandler.java @@ -1,170 +1,177 @@ /* * 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.async; import android.os.Handler; import android.os.Looper; import android.util.Log; import java.util.HashMap; import java.util.Map; import java.util.concurrent.BlockingQueue; import java.util.concurrent.CancellationException; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import androidx.annotation.Nullable; +/** + * Scheduler for {@link BackgroundJob} objects. + *

+ * We use an internal {@link ThreadPoolExecutor} to catch Exceptions and + * pass them along to {@link #handleUncaughtException(Future, Throwable)}. + *

+ */ public class BackgroundJobHandler { private static final String TAG = BackgroundJobHandler.class.getSimpleName(); private final Map> jobMap = new HashMap<>(); private final Object jobMapLock = new Object(); private class MyThreadPoolExecutor extends ThreadPoolExecutor { MyThreadPoolExecutor(int corePoolSize, int maxPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue workQueue) { super(corePoolSize, maxPoolSize, keepAliveTime, unit, workQueue); } @Override protected void afterExecute(Runnable r, Throwable t) { super.afterExecute(r, t); if (!(r instanceof Future)) { return; } Future future = (Future) r; if (t == null) { try { future.get(); } catch (CancellationException ce) { Log.d(TAG,"afterExecute got a CancellationException"); } catch (ExecutionException ee) { t = ee; } catch (InterruptedException ie) { Log.d(TAG, "afterExecute got an InterruptedException"); Thread.currentThread().interrupt(); // ignore/reset } } if (t != null) { BackgroundJobHandler.this.handleUncaughtException(future, t); } } } private final ThreadPoolExecutor threadPoolExecutor; private Handler handler; private BackgroundJobHandler(int corePoolSize, int maxPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue workQueue) { this.handler = new Handler(Looper.getMainLooper()); this.threadPoolExecutor = new MyThreadPoolExecutor(corePoolSize, maxPoolSize, keepAliveTime, unit, workQueue); } public void runJob(BackgroundJob bgJob) { Future f; bgJob.setBackgroundJobHandler(this); try { synchronized (jobMapLock) { f = threadPoolExecutor.submit(bgJob); jobMap.put(bgJob, f); } } catch (RejectedExecutionException e) { Log.d(TAG,"threadPoolExecutor.submit rejected a background job: " + e.getMessage()); bgJob.reportError(e); } } public boolean isRunning(long jobId) { synchronized (jobMapLock) { for (BackgroundJob job : jobMap.keySet()) { if (job.getId() == jobId) { return true; } } } return false; } @Nullable public BackgroundJob getJob(long jobId) { synchronized (jobMapLock) { for (BackgroundJob job : jobMap.keySet()) { if (job.getId() == jobId) { return job; } } } return null; } void cancelJob(BackgroundJob job) { synchronized (jobMapLock) { if (jobMap.containsKey(job)) { Future f = jobMap.get(job); if (f.cancel(true)) { threadPoolExecutor.purge(); } jobMap.remove(job); } } } private void handleUncaughtException(Future ft, Throwable t) { synchronized (jobMapLock) { for (Map.Entry> pairs : jobMap.entrySet()) { Future future = pairs.getValue(); if (future == ft) { pairs.getKey().reportError(t); break; } } } } void onFinished(BackgroundJob job) { synchronized (jobMapLock) { jobMap.remove(job); } } void runOnUiThread(Runnable runnable) { handler.post(runnable); } public static BackgroundJobHandler newFixedThreadPoolBackgroundJobHander(int numThreads) { return new BackgroundJobHandler(numThreads, numThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>()); } }