diff --git a/AndroidManifest.xml b/AndroidManifest.xml --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -240,6 +240,12 @@ android:value="org.kde.kdeconnect.Plugins.SharePlugin.ShareChooserTargetService" /> + + + + + + + * + * 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.annotation.NonNull; +import android.support.v4.provider.DocumentFile; + +import org.kde.kdeconnect.async.BackgroundJob; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public class ReceiveFileJob extends BackgroundJob { + public ReceiveFileJob(@NonNull RequestInfo requestInfo, @NonNull BackgroundJob.Callback callback) { + super(requestInfo, callback); + } + + @Override + public void run() { + try { + byte data[] = new byte[4096]; + long progress = 0, prevProgressPercentage = -1; + int count; + + while ((count = requestInfo.inputStream.read(data)) >= 0 && !canceled) { + progress += count; + requestInfo.outputStream.write(data, 0, count); + + if (requestInfo.fileSize > 0) { + if (progress >= requestInfo.fileSize) break; + int progressPercentage = (int)(progress * 100 / requestInfo.fileSize); + if (progressPercentage != prevProgressPercentage) { + prevProgressPercentage = progressPercentage; + reportProgress(progressPercentage); + } + } + } + + requestInfo.outputStream.flush(); + + if (!canceled) { + if (progress == requestInfo.fileSize) { + reportResult(null); + } else { + reportError(new IOException("Did not receive the complete file, the remote party probably canceled the transfer")); + } + } + } catch (Exception e) { + reportError(e); + } finally { + try { + requestInfo.outputStream.close(); + } catch (Exception e) { + //Ignore + } + try { + requestInfo.inputStream.close(); + } catch (Exception e) { + //Ignore + } + } + } + + static class RequestInfo { + @NonNull final InputStream inputStream; + final long fileSize; + @NonNull final OutputStream outputStream; + @NonNull final DocumentFile outputDocumentFile; + + RequestInfo(@NonNull InputStream inputStream, long fileSize, @NonNull OutputStream outputStream, @NonNull DocumentFile outputDocumentFile) { + this.inputStream = inputStream; + this.fileSize = fileSize; + this.outputStream = outputStream; + this.outputDocumentFile = outputDocumentFile; + } + } +} diff --git a/src/org/kde/kdeconnect/Plugins/SharePlugin/SendFileActivity.java b/src/org/kde/kdeconnect/Plugins/SharePlugin/SendFileActivity.java --- a/src/org/kde/kdeconnect/Plugins/SharePlugin/SendFileActivity.java +++ b/src/org/kde/kdeconnect/Plugins/SharePlugin/SendFileActivity.java @@ -96,7 +96,9 @@ finish(); return; } - SharePlugin.queuedSendUriList(getApplicationContext(), device, uris); + + SharePlugin plugin = device.getPlugin(SharePlugin.class); + plugin.queuedSendUriList(getApplicationContext(), device, uris); }); } } diff --git a/src/org/kde/kdeconnect/Plugins/SharePlugin/SendPacketListJob.java b/src/org/kde/kdeconnect/Plugins/SharePlugin/SendPacketListJob.java new file mode 100644 --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/SharePlugin/SendPacketListJob.java @@ -0,0 +1,104 @@ +/* + * 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 org.kde.kdeconnect.Device; +import org.kde.kdeconnect.NetworkPacket; +import org.kde.kdeconnect.async.BackgroundJob; + +import java.io.IOException; +import java.util.List; + +//TODO: This is a hack to make sending files cancelable. It would be better if Link::sendPacket would always start a BackgroundJob using BackgroundJobHandler (less expensive than creating new Threads all the time +public class SendPacketListJob extends BackgroundJob { + public SendPacketListJob(RequestInfo requestInfo, Callback callback) { + super(requestInfo, callback); + } + + @Override + public void run() { + boolean success = true; + + for (int i = 0, numPackets = requestInfo.networkPackets.size(); i < numPackets && !canceled; i++) { + success = requestInfo.device.sendPacketBlocking(requestInfo.networkPackets.get(i), new SendPacketStatusCallback()); + + if (!success) { + break; + } + } + + if (!canceled) { + if (success) { + reportResult(null); + } else { + reportError(new Throwable("Sending files failed")); + } + } + } + + @Override + public void cancel() { + super.cancel(); + + for (NetworkPacket np : requestInfo.networkPackets) { + try { + np.getPayload().close(); + } catch (IOException e) { + //Ignored + } + } + } + + public static class RequestInfo { + Device device; + List networkPackets; + Device.SendPacketStatusCallback sendPacketStatusCallback; + + public RequestInfo(Device device, List networkPackets, Device.SendPacketStatusCallback sendPacketStatusCallback) { + this.device = device; + this.networkPackets = networkPackets; + this.sendPacketStatusCallback = sendPacketStatusCallback; + } + } + + private class SendPacketStatusCallback extends Device.SendPacketStatusCallback { + @Override + public void onProgressChanged(int percent) { + if (!canceled) { + requestInfo.sendPacketStatusCallback.onProgressChanged(percent); + } + } + + @Override + public void onSuccess() { + if (!canceled) { + requestInfo.sendPacketStatusCallback.onSuccess(); + } + } + + @Override + public void onFailure(Throwable e) { + if (!canceled) { + requestInfo.sendPacketStatusCallback.onFailure(e); + } + } + } +} diff --git a/src/org/kde/kdeconnect/Plugins/SharePlugin/ShareActivity.java b/src/org/kde/kdeconnect/Plugins/SharePlugin/ShareActivity.java --- a/src/org/kde/kdeconnect/Plugins/SharePlugin/ShareActivity.java +++ b/src/org/kde/kdeconnect/Plugins/SharePlugin/ShareActivity.java @@ -113,7 +113,8 @@ list.setOnItemClickListener((adapterView, view, i, l) -> { Device device = devicesList.get(i - 1); //NOTE: -1 because of the title! - SharePlugin.share(intent, device); + SharePlugin plugin = device.getPlugin(SharePlugin.class); + plugin.share(intent, device); finish(); }); }); @@ -152,7 +153,8 @@ Log.d("DirectShare", "sharing to " + service.getDevice(deviceId).getName()); Device device = service.getDevice(deviceId); if (device.isReachable() && device.isPaired()) { - SharePlugin.share(intent, device); + SharePlugin plugin = device.getPlugin(SharePlugin.class); + plugin.share(intent, device); } finish(); }); diff --git a/src/org/kde/kdeconnect/Plugins/SharePlugin/ShareBroadcastReceiver.java b/src/org/kde/kdeconnect/Plugins/SharePlugin/ShareBroadcastReceiver.java new file mode 100644 --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/SharePlugin/ShareBroadcastReceiver.java @@ -0,0 +1,61 @@ +/* + * 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.NotificationManager; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import org.kde.kdeconnect.async.BackgroundJob; +import org.kde.kdeconnect.async.BackgroundJobHandler; + +public class ShareBroadcastReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + switch (intent.getAction()) { + case SharePlugin.ACTION_CANCEL_SHARE: + cancelShare(context, intent); + break; + default: + Log.d("ShareBroadcastReceiver", "Unhandled Action received: " + intent.getAction()); + } + } + + private void cancelShare(Context context, Intent intent) { + int notificationId = intent.getIntExtra(SharePlugin.CANCEL_SHARE_NOTIFICATION_ID_EXTRA, -1); + long jobId = intent.getLongExtra( SharePlugin.CANCEL_SHARE_BACKGROUND_JOB_ID_EXTRA, -1); + + BackgroundJobHandler handler = BackgroundJobHandler.getInstance(); + if (handler.isRunning(jobId)) { + BackgroundJob job = handler.getJob(jobId); + job.cancel(); + + NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.cancel(notificationId); + + if (job instanceof ReceiveFileJob) { + ((ReceiveFileJob)job).getRequestInfo().outputDocumentFile.delete(); + } + } + } +} diff --git a/src/org/kde/kdeconnect/Plugins/SharePlugin/ShareNotification.java b/src/org/kde/kdeconnect/Plugins/SharePlugin/ShareNotification.java --- a/src/org/kde/kdeconnect/Plugins/SharePlugin/ShareNotification.java +++ b/src/org/kde/kdeconnect/Plugins/SharePlugin/ShareNotification.java @@ -69,7 +69,18 @@ NotificationHelper.notifyCompat(notificationManager, notificationId, builder.build()); } - public int getId() { + public void setJobId(long jobId) { + Intent cancelIntent = new Intent(device.getContext(), ShareBroadcastReceiver.class); + cancelIntent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND); + cancelIntent.setAction(SharePlugin.ACTION_CANCEL_SHARE); + cancelIntent.putExtra(SharePlugin.CANCEL_SHARE_BACKGROUND_JOB_ID_EXTRA, jobId); + cancelIntent.putExtra(SharePlugin.CANCEL_SHARE_NOTIFICATION_ID_EXTRA, notificationId); + PendingIntent cancelPendingIntent = PendingIntent.getBroadcast(device.getContext(), 0, cancelIntent, PendingIntent.FLAG_UPDATE_CURRENT); + + builder.addAction(R.drawable.ic_reject_pairing, device.getContext().getString(R.string.cancel), cancelPendingIntent); + } + + public int getNotificationId() { return notificationId; } @@ -106,6 +117,7 @@ //If it's an image, try to show it in the notification if (mimeType.startsWith("image/")) { try { + //TODO: This reads the image in its original dimensions which can result in a huge bitmap see: https://developer.android.com/topic/performance/graphics/load-bitmap Bitmap image = BitmapFactory.decodeStream(device.getContext().getContentResolver().openInputStream(destinationUri)); if (image != null) { builder.setLargeIcon(image); diff --git a/src/org/kde/kdeconnect/Plugins/SharePlugin/SharePlugin.java b/src/org/kde/kdeconnect/Plugins/SharePlugin/SharePlugin.java --- a/src/org/kde/kdeconnect/Plugins/SharePlugin/SharePlugin.java +++ b/src/org/kde/kdeconnect/Plugins/SharePlugin/SharePlugin.java @@ -37,6 +37,8 @@ import android.os.Build; import android.os.Bundle; import android.provider.MediaStore; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.support.v4.app.NotificationCompat; import android.support.v4.app.TaskStackBuilder; import android.support.v4.content.ContextCompat; @@ -51,6 +53,8 @@ import org.kde.kdeconnect.NetworkPacket; import org.kde.kdeconnect.Plugins.Plugin; import org.kde.kdeconnect.UserInterface.SettingsActivity; +import org.kde.kdeconnect.async.BackgroundJob; +import org.kde.kdeconnect.async.BackgroundJobHandler; import org.kde.kdeconnect_tp.R; import java.io.File; @@ -61,6 +65,9 @@ import java.util.ArrayList; public class SharePlugin extends Plugin { + public final static String ACTION_CANCEL_SHARE = "org.kde.kdeconnect.Plugins.SharePlugin.CancelShare"; + public final static String CANCEL_SHARE_NOTIFICATION_ID_EXTRA = "notificationId"; + public final static String CANCEL_SHARE_BACKGROUND_JOB_ID_EXTRA = "backgroundJobId"; public final static String PACKET_TYPE_SHARE_REQUEST = "kdeconnect.share.request"; @@ -219,64 +226,56 @@ final ShareNotification notification = new ShareNotification(device, filename); notification.show(); - new Thread(() -> { - try { - byte data[] = new byte[4096]; - long progress = 0, prevProgressPercentage = -1; - int count; - long lastUpdate = 0; - while ((count = input.read(data)) >= 0) { - progress += count; - destinationOutput.write(data, 0, count); - if (fileLength > 0) { - if (progress >= fileLength) break; - long progressPercentage = (progress * 100 / fileLength); - if (progressPercentage != prevProgressPercentage && - System.currentTimeMillis() - lastUpdate > 100) { - prevProgressPercentage = progressPercentage; - lastUpdate = System.currentTimeMillis(); - - notification.setProgress((int) progressPercentage); - notification.show(); - } - } - //else Log.e("SharePlugin", "Infinite loop? :D"); - } + ReceiveFileJob.RequestInfo requestInfo = new ReceiveFileJob.RequestInfo(input, fileLength, destinationOutput, destinationDocument); + ReceiveFileJob job = new ReceiveFileJob(requestInfo, new ReceiveFileJob.Callback() { + long lastUpdate = 0; - destinationOutput.flush(); - - Log.i("SharePlugin", "Transfer finished: " + destinationUri.getPath()); + @Override + public void onResult(@NonNull BackgroundJob job, @Nullable Void result) { + Log.i("SharePlugin", "onResult(): Transfer finished: " + destinationUri.getPath()); //Update the notification and allow to open the file from it notification.setFinished(true); notification.setURI(destinationUri, mimeType); notification.show(); - if (!customDestination && Build.VERSION.SDK_INT >= 12) { + if (!customDestination) { Log.i("SharePlugin", "Adding to downloads"); DownloadManager manager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); - manager.addCompletedDownload(destinationUri.getLastPathSegment(), device.getName(), true, mimeType, destinationUri.getPath(), fileLength, false); + if (manager != null) { + manager.addCompletedDownload(destinationUri.getLastPathSegment(), device.getName(), true, mimeType, destinationUri.getPath(), fileLength, false); + } } else { //Make sure it is added to the Android Gallery anyway MediaStoreHelper.indexFile(context, destinationUri); } + } - } catch (Exception e) { - Log.e("SharePlugin", "Receiver thread exception"); - e.printStackTrace(); + @Override + public void onProgress(@NonNull BackgroundJob job, @NonNull Integer progress) { + Log.i("SharePlugin", "onProgress(" + progress + ")"); + long now = System.currentTimeMillis(); + if (progress == 100 || now - lastUpdate > 500) { + notification.setProgress(progress); + notification.show(); + lastUpdate = now; + } + } + + @Override + public void onError(@NonNull BackgroundJob job, @NonNull Throwable error) { + Log.e("SharePlugin", "onError(): " + error.getMessage()); + error.printStackTrace(); notification.setFinished(false); notification.show(); - } finally { - try { - destinationOutput.close(); - } catch (Exception e) { - } - try { - input.close(); - } catch (Exception e) { - } + + destinationDocument.delete(); } - }).start(); + }); + + notification.setJobId(job.getId()); + notification.show(); + BackgroundJobHandler.getInstance().runJob(job); } @Override @@ -287,7 +286,7 @@ parentActivity.startActivity(intent); } - static void queuedSendUriList(Context context, final Device device, final ArrayList uriList) { + void queuedSendUriList(Context context, final Device device, 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<>(); @@ -297,23 +296,25 @@ //Callback that shows a progress notification final NotificationUpdateCallback notificationUpdateCallback = new NotificationUpdateCallback(context, device, toSend); +//TODO: Make ShareNotification useful for both up- and download (eg. add methods setTitle() and setText()) - //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(); + SendPacketListJob.RequestInfo requestInfo = new SendPacketListJob.RequestInfo(device, toSend, notificationUpdateCallback); + SendPacketListJob job = new SendPacketListJob(requestInfo, new SendPacketListJob.Callback() { + @Override + public void onResult(@NonNull BackgroundJob backgroundJob, @Nullable Void result) { } - }).start(); + @Override + public void onProgress(@NonNull BackgroundJob backgroundJob, @Nullable Void result) { + } + + @Override + public void onError(@NonNull BackgroundJob backgroundJob, @NonNull Throwable error) { + } + }); + + notificationUpdateCallback.setJobId(job.getId()); + BackgroundJobHandler.getInstance().runJob(job); } //Create the network package from the URI @@ -380,7 +381,6 @@ } catch (Exception e) { } } - } np.setPayload(inputStream, size); @@ -393,7 +393,7 @@ } } - public static void share(Intent intent, Device device) { + public void share(Intent intent, Device device) { Bundle extras = intent.getExtras(); if (extras != null) { if (extras.containsKey(Intent.EXTRA_STREAM)) { @@ -409,7 +409,7 @@ uriList.add(uri); } - SharePlugin.queuedSendUriList(device.getContext(), device, uriList); + queuedSendUriList(device.getContext(), device, uriList); } catch (Exception e) { Log.e("ShareActivity", "Exception"); @@ -444,7 +444,6 @@ device.sendPacket(np); } } - } @Override diff --git a/src/org/kde/kdeconnect/async/BackgroundJob.java b/src/org/kde/kdeconnect/async/BackgroundJob.java new file mode 100644 --- /dev/null +++ b/src/org/kde/kdeconnect/async/BackgroundJob.java @@ -0,0 +1,78 @@ +/* + * 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.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import java.util.concurrent.atomic.AtomicLong; + +public abstract class BackgroundJob implements Runnable { + private static AtomicLong atomicLong = new AtomicLong(0); + protected volatile boolean canceled; + protected BackgroundJobHandler backgroundJobHandler; + private long id; + + protected I requestInfo; + private Callback callback; + + public BackgroundJob(I requestInfo, Callback callback) { + this.id = atomicLong.incrementAndGet(); + this.requestInfo = requestInfo; + this.callback = callback; + } + + void setBackgroundJobHandler(BackgroundJobHandler handler) { + this.backgroundJobHandler = handler; + } + + public long getId() { return id; } + public I getRequestInfo() { return requestInfo; } + + public void cancel() { + canceled = true; + backgroundJobHandler.cancelJob(this); + } + + public boolean isCancelled() { + return canceled; + } + + public interface Callback { + void onResult(@NonNull BackgroundJob backgroundJob, @Nullable R result); + void onProgress(@NonNull BackgroundJob backgroundJob, @Nullable P result); + void onError(@NonNull BackgroundJob backgroundJob, @NonNull Throwable error); + } + + protected void reportResult(@Nullable R result) { + backgroundJobHandler.runOnUiThread(() -> callback.onResult(this, result)); + backgroundJobHandler.onFinished(this); + } + + protected void reportProgress(@Nullable P progress) { + backgroundJobHandler.runOnUiThread(() -> callback.onProgress( this, progress)); + } + + protected void reportError(@NonNull Throwable error) { + backgroundJobHandler.runOnUiThread(() -> callback.onError( this, error)); + backgroundJobHandler.onFinished(this); + } +} diff --git a/src/org/kde/kdeconnect/async/BackgroundJobHandler.java b/src/org/kde/kdeconnect/async/BackgroundJobHandler.java new file mode 100644 --- /dev/null +++ b/src/org/kde/kdeconnect/async/BackgroundJobHandler.java @@ -0,0 +1,206 @@ +/* + * 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.support.annotation.NonNull; +import android.support.annotation.Nullable; +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.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +public class BackgroundJobHandler { + private static final String TAG = BackgroundJobHandler.class.getSimpleName(); + + private static BackgroundJobHandler instance; + + private static final int CORE_POOL_SIZE = 5; + private static final int MAXIMUM_POOL_SIZE = 10; + private static final int KEEP_ALIVE = 1; + + private final Map> jobMap = new HashMap<>(); + private final Object jobMapLock = new Object(); + + private final ThreadFactory threadFactory = new ThreadFactory() { + private final AtomicInteger threadNum = new AtomicInteger(1); + + public Thread newThread(@NonNull Runnable r) { + return new Thread(r, "BGThread #" + threadNum.getAndIncrement()); + } + }; + + private final BlockingQueue poolWorkQueue = new LinkedBlockingQueue<>(10); + + private class MyThreadPoolExecutor extends ThreadPoolExecutor { + MyThreadPoolExecutor(BlockingQueue workQueue, ThreadFactory threadFactory) { + super(CORE_POOL_SIZE, MAXIMUM_POOL_SIZE, KEEP_ALIVE, TimeUnit.SECONDS, workQueue, threadFactory); + } + + @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 an CancellationException"); + //Thread.currentThread().interrupt(); + } 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 threadPool; + private Handler handler; + + private BackgroundJobHandler() { + this.handler = new Handler(Looper.getMainLooper()); + this.threadPool = new MyThreadPoolExecutor(poolWorkQueue, threadFactory); + } + + public static BackgroundJobHandler getInstance() { + if (instance == null) { + synchronized (BackgroundJobHandler.class) { + if (instance == null) { + instance = new BackgroundJobHandler(); + } + } + } + + return instance; + } + + //TODO: Call onError instead of returning true of false + public boolean runJob(BackgroundJob bgJob) { + Future f; + + bgJob.setBackgroundJobHandler(this); + + try { + synchronized (jobMapLock) { + f = threadPool.submit(bgJob); + jobMap.put(bgJob, f); + } + } catch (RejectedExecutionException e) { + Log.d(TAG,"threadPool.submit rejected a background job: " + e.getMessage()); + + return false; + } + + return true; + } + + 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; + } + + /** + * Cancel a job + * + * @param job The job to cancel + * @return true if the job was cancelled successfully, false otherwise + */ + void cancelJob(BackgroundJob job) { + synchronized (jobMapLock) { + if (jobMap.containsKey(job)) { + Future f = jobMap.get(job); + + if (f.cancel(true)) { + threadPool.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); + } +}