Commit f2e505b8 authored by Erik Duisters's avatar Erik Duisters
Browse files

Allow shares from desktop to be canceled

Summary:
Allow in progress file transfers to be canceled

BUG: 349956

{F6373048}

{F6373050}

{F6373051}

Test Plan:
Send a large file from desktop to android
Press cancel in the progress notification

Result: the file transfer is cancelled and the cancelled file is deleted from storage

Reviewers: #kde_connect, nicolasfella, albertvaka

Reviewed By: #kde_connect, albertvaka

Subscribers: albertvaka, nicolasfella, kdeconnect

Tags: #kde_connect

Differential Revision: https://phabricator.kde.org/D16491
parent a6fdddf8
......@@ -224,6 +224,12 @@
android:value="org.kde.kdeconnect.Plugins.SharePlugin.ShareChooserTargetService" />
</activity>
<receiver android:name="org.kde.kdeconnect.Plugins.SharePlugin.ShareBroadcastReceiver">
<intent-filter>
<action android:name="org.kde.kdeconnect.Plugins.SharePlugin.CancelShare" />
</intent-filter>
</receiver>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="org.kde.kdeconnect_tp.fileprovider"
......
......@@ -25,14 +25,13 @@ import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
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;
......@@ -46,13 +45,7 @@ import java.util.List;
import androidx.core.content.FileProvider;
import androidx.documentfile.provider.DocumentFile;
class CompositeReceiveFileRunnable implements Runnable {
interface CallBack {
void onSuccess(CompositeReceiveFileRunnable runnable);
void onError(CompositeReceiveFileRunnable runnable, Throwable error);
}
private final Device device;
public class CompositeReceiveFileJob extends BackgroundJob<Device, Void> {
private final ShareNotification shareNotification;
private NetworkPacket currentNetworkPacket;
private String currentFileName;
......@@ -61,29 +54,29 @@ class CompositeReceiveFileRunnable implements Runnable {
private long lastProgressTimeMillis;
private long prevProgressPercentage;
private final CallBack callBack;
private final Handler handler;
private final Object lock; //Use to protect concurrent access to the variables below
private final List<NetworkPacket> networkPacketList;
private int totalNumFiles;
private long totalPayloadSize;
private boolean isRunning;
CompositeReceiveFileRunnable(Device device, CallBack callBack) {
this.device = device;
this.callBack = callBack;
CompositeReceiveFileJob(Device device, BackgroundJob.Callback<Void> callBack) {
super(device, callBack);
lock = new Object();
networkPacketList = new ArrayList<>();
shareNotification = new ShareNotification(device);
shareNotification.addCancelAction(getId());
currentFileNum = 0;
totalNumFiles = 0;
totalPayloadSize = 0;
totalReceived = 0;
lastProgressTimeMillis = 0;
prevProgressPercentage = 0;
handler = new Handler(Looper.getMainLooper());
}
private Device getDevice() {
return requestInfo;
}
boolean isRunning() { return isRunning; }
......@@ -93,8 +86,8 @@ class CompositeReceiveFileRunnable implements Runnable {
this.totalNumFiles = numberOfFiles;
this.totalPayloadSize = totalPayloadSize;
shareNotification.setTitle(device.getContext().getResources()
.getQuantityString(R.plurals.incoming_file_title, totalNumFiles, totalNumFiles, device.getName()));
shareNotification.setTitle(getDevice().getContext().getResources()
.getQuantityString(R.plurals.incoming_file_title, totalNumFiles, totalNumFiles, getDevice().getName()));
}
}
......@@ -106,8 +99,8 @@ class CompositeReceiveFileRunnable implements Runnable {
totalNumFiles = networkPacket.getInt(SharePlugin.KEY_NUMBER_OF_FILES, 1);
totalPayloadSize = networkPacket.getLong(SharePlugin.KEY_TOTAL_PAYLOAD_SIZE);
shareNotification.setTitle(device.getContext().getResources()
.getQuantityString(R.plurals.incoming_file_title, totalNumFiles, totalNumFiles, device.getName()));
shareNotification.setTitle(getDevice().getContext().getResources()
.getQuantityString(R.plurals.incoming_file_title, totalNumFiles, totalNumFiles, getDevice().getName()));
}
}
}
......@@ -126,7 +119,7 @@ class CompositeReceiveFileRunnable implements Runnable {
isRunning = true;
while (!done) {
while (!done && !canceled) {
synchronized (lock) {
currentNetworkPacket = networkPacketList.get(0);
}
......@@ -138,7 +131,7 @@ class CompositeReceiveFileRunnable implements Runnable {
fileDocument = getDocumentFileFor(currentFileName, currentNetworkPacket.getBoolean("open"));
if (currentNetworkPacket.hasPayload()) {
outputStream = new BufferedOutputStream(device.getContext().getContentResolver().openOutputStream(fileDocument.getUri()));
outputStream = new BufferedOutputStream(getDevice().getContext().getContentResolver().openOutputStream(fileDocument.getUri()));
InputStream inputStream = currentNetworkPacket.getPayload().getInputStream();
long received = receiveFile(inputStream, outputStream);
......@@ -147,7 +140,10 @@ class CompositeReceiveFileRunnable implements Runnable {
if ( received != currentNetworkPacket.getPayloadSize()) {
fileDocument.delete();
throw new RuntimeException("Failed to receive: " + currentFileName + " received:" + received + " bytes, expected: " + currentNetworkPacket.getPayloadSize() + " bytes");
if (!canceled) {
throw new RuntimeException("Failed to receive: " + currentFileName + " received:" + received + " bytes, expected: " + currentNetworkPacket.getPayloadSize() + " bytes");
}
} else {
publishFile(fileDocument, received);
}
......@@ -163,7 +159,7 @@ class CompositeReceiveFileRunnable implements Runnable {
listIsEmpty = networkPacketList.isEmpty();
}
if (listIsEmpty) {
if (listIsEmpty && !canceled) {
try {
Thread.sleep(1000);
} catch (InterruptedException ignored) {}
......@@ -182,6 +178,11 @@ class CompositeReceiveFileRunnable implements Runnable {
isRunning = false;
if (canceled) {
shareNotification.cancel();
return;
}
int numFiles;
synchronized (lock) {
numFiles = totalNumFiles;
......@@ -192,7 +193,7 @@ class CompositeReceiveFileRunnable implements Runnable {
openFile(fileDocument);
} else {
//Update the notification and allow to open the file from it
shareNotification.setFinished(device.getContext().getResources().getQuantityString(R.plurals.received_files_title, numFiles, device.getName(), numFiles));
shareNotification.setFinished(getDevice().getContext().getResources().getQuantityString(R.plurals.received_files_title, numFiles, getDevice().getName(), numFiles));
if (totalNumFiles == 1 && fileDocument != null) {
shareNotification.setURI(fileDocument.getUri(), fileDocument.getType(), fileDocument.getName());
......@@ -200,7 +201,7 @@ class CompositeReceiveFileRunnable implements Runnable {
shareNotification.show();
}
handler.post(() -> callBack.onSuccess(this));
reportResult(null);
} catch (Exception e) {
isRunning = false;
......@@ -208,9 +209,10 @@ class CompositeReceiveFileRunnable implements Runnable {
synchronized (lock) {
failedFiles = (totalNumFiles - currentFileNum + 1);
}
shareNotification.setFinished(device.getContext().getResources().getQuantityString(R.plurals.received_files_fail_title, failedFiles, device.getName(), failedFiles, totalNumFiles));
shareNotification.setFinished(getDevice().getContext().getResources().getQuantityString(R.plurals.received_files_fail_title, failedFiles, getDevice().getName(), failedFiles, totalNumFiles));
shareNotification.show();
handler.post(() -> callBack.onError(this, e));
reportError(e);
} finally {
closeAllInputStreams();
networkPacketList.clear();
......@@ -230,12 +232,12 @@ class CompositeReceiveFileRunnable implements Runnable {
//We need to check for already existing files only when storing in the default path.
//User-defined paths use the new Storage Access Framework that already handles this.
//If the file should be opened immediately store it in the standard location to avoid the FileProvider trouble (See ShareNotification::setURI)
if (open || !ShareSettingsFragment.isCustomDestinationEnabled(device.getContext())) {
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(device.getContext());
destinationFolderDocument = ShareSettingsFragment.getDestinationDirectory(getDevice().getContext());
}
String displayName = FilesHelper.getFileNameWithoutExt(filenameToUse);
String mimeType = FilesHelper.getMimeTypeFromFile(filenameToUse);
......@@ -247,7 +249,7 @@ class CompositeReceiveFileRunnable implements Runnable {
DocumentFile fileDocument = destinationFolderDocument.createFile(mimeType, displayName);
if (fileDocument == null) {
throw new RuntimeException(device.getContext().getString(R.string.cannot_create_file, filenameToUse));
throw new RuntimeException(getDevice().getContext().getString(R.string.cannot_create_file, filenameToUse));
}
return fileDocument;
......@@ -258,7 +260,7 @@ class CompositeReceiveFileRunnable implements Runnable {
int count;
long received = 0;
while ((count = input.read(data)) >= 0) {
while ((count = input.read(data)) >= 0 && !canceled) {
received += count;
totalReceived += count;
......@@ -291,21 +293,21 @@ class CompositeReceiveFileRunnable implements Runnable {
private void setProgress(int progress) {
synchronized (lock) {
shareNotification.setProgress(progress, device.getContext().getResources()
shareNotification.setProgress(progress, getDevice().getContext().getResources()
.getQuantityString(R.plurals.incoming_files_text, totalNumFiles, currentFileName, currentFileNum, totalNumFiles));
}
shareNotification.show();
}
private void publishFile(DocumentFile fileDocument, long size) {
if (!ShareSettingsFragment.isCustomDestinationEnabled(device.getContext())) {
if (!ShareSettingsFragment.isCustomDestinationEnabled(getDevice().getContext())) {
Log.i("SharePlugin", "Adding to downloads");
DownloadManager manager = (DownloadManager) device.getContext().getSystemService(Context.DOWNLOAD_SERVICE);
manager.addCompletedDownload(fileDocument.getUri().getLastPathSegment(), device.getName(), true, fileDocument.getType(), fileDocument.getUri().getPath(), size, false);
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(device.getContext(), fileDocument.getUri());
MediaStoreHelper.indexFile(getDevice().getContext(), fileDocument.getUri());
}
}
......@@ -315,13 +317,13 @@ class CompositeReceiveFileRunnable implements Runnable {
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(device.getContext(), "org.kde.kdeconnect_tp.fileprovider", file);
Uri contentUri = FileProvider.getUriForFile(getDevice().getContext(), "org.kde.kdeconnect_tp.fileprovider", file);
intent.setDataAndType(contentUri, mimeType);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
} else {
intent.setDataAndType(fileDocument.getUri(), mimeType);
}
device.getContext().startActivity(intent);
getDevice().getContext().startActivity(intent);
}
}
......@@ -147,7 +147,6 @@ public class ShareActivity extends AppCompatActivity {
final String deviceId = intent.getStringExtra("deviceId");
if (deviceId != null) {
BackgroundService.runWithPlugin(this, deviceId, SharePlugin.class, plugin -> {
plugin.share(intent);
finish();
......
/*
* Copyright 2018 Erik Duisters <e.duisters1@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package org.kde.kdeconnect.Plugins.SharePlugin;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.util.Log;
import org.kde.kdeconnect.BackgroundService;
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) {
if (!intent.hasExtra(SharePlugin.CANCEL_SHARE_BACKGROUND_JOB_ID_EXTRA) ||
!intent.hasExtra(SharePlugin.CANCEL_SHARE_DEVICE_ID_EXTRA)) {
Log.e("ShareBroadcastReceiver", "cancelShare() - not all expected extra's are present. Ignoring this cancel intent");
return;
}
long jobId = intent.getLongExtra(SharePlugin.CANCEL_SHARE_BACKGROUND_JOB_ID_EXTRA, -1);
String deviceId = intent.getStringExtra(SharePlugin.CANCEL_SHARE_DEVICE_ID_EXTRA);
BackgroundService.RunCommand(context, service -> {
SharePlugin plugin = service.getDevice(deviceId).getPlugin(SharePlugin.class);
plugin.cancelJob(jobId);
});
}
}
......@@ -48,6 +48,7 @@ class ShareNotification {
private final int notificationId;
private NotificationCompat.Builder builder;
private final Device device;
private long currentJobId;
//https://documentation.onesignal.com/docs/android-customizations#section-big-picture
private static final int bigImageWidth = 1440;
......@@ -73,7 +74,23 @@ class ShareNotification {
notificationManager.cancel(notificationId);
}
public int getId() {
public void addCancelAction(long jobId) {
builder.mActions.clear();
currentJobId = jobId;
Intent cancelIntent = new Intent(device.getContext(), ShareBroadcastReceiver.class);
cancelIntent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND);
cancelIntent.setAction(SharePlugin.ACTION_CANCEL_SHARE);
cancelIntent.putExtra(SharePlugin.CANCEL_SHARE_BACKGROUND_JOB_ID_EXTRA, jobId);
cancelIntent.putExtra(SharePlugin.CANCEL_SHARE_DEVICE_ID_EXTRA, device.getDeviceId());
PendingIntent cancelPendingIntent = PendingIntent.getBroadcast(device.getContext(), 0, cancelIntent, PendingIntent.FLAG_UPDATE_CURRENT);
builder.addAction(R.drawable.ic_reject_pairing, device.getContext().getString(R.string.cancel), cancelPendingIntent);
}
public long getCurrentJobId() { return currentJobId; }
public int getNotificationId() {
return notificationId;
}
......
......@@ -43,19 +43,24 @@ 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 java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import androidx.core.app.NotificationCompat;
import androidx.core.content.ContextCompat;
@PluginFactory.LoadablePlugin
public class SharePlugin extends Plugin {
final static String ACTION_CANCEL_SHARE = "org.kde.kdeconnect.Plugins.SharePlugin.CancelShare";
final static String CANCEL_SHARE_DEVICE_ID_EXTRA = "deviceId";
final static String CANCEL_SHARE_BACKGROUND_JOB_ID_EXTRA = "backgroundJobId";
private final static String PACKET_TYPE_SHARE_REQUEST = "kdeconnect.share.request";
private final static String PACKET_TYPE_SHARE_REQUEST_UPDATE = "kdeconnect.share.request.update";
......@@ -63,15 +68,16 @@ public class SharePlugin extends Plugin {
final static String KEY_TOTAL_PAYLOAD_SIZE = "totalPayloadSize";
private final static boolean openUrlsDirectly = true;
private ExecutorService executorService;
private BackgroundJobHandler backgroundJobHandler;
private final Handler handler;
private CompositeReceiveFileRunnable receiveFileRunnable;
private final Callback receiveFileRunnableCallback;
private CompositeReceiveFileJob receiveFileJob;
private final Callback receiveFileJobCallback;
public SharePlugin() {
executorService = Executors.newFixedThreadPool(5);
backgroundJobHandler = BackgroundJobHandler.newFixedThreadPoolBackgroundJobHander(5);
handler = new Handler(Looper.getMainLooper());
receiveFileRunnableCallback = new Callback();
receiveFileJobCallback = new Callback();
}
@Override
......@@ -122,8 +128,8 @@ public class SharePlugin extends Plugin {
public boolean onPacketReceived(NetworkPacket np) {
try {
if (np.getType().equals(PACKET_TYPE_SHARE_REQUEST_UPDATE)) {
if (receiveFileRunnable != null && receiveFileRunnable.isRunning()) {
receiveFileRunnable.updateTotals(np.getInt(KEY_NUMBER_OF_FILES), np.getLong(KEY_TOTAL_PAYLOAD_SIZE));
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");
}
......@@ -200,15 +206,15 @@ public class SharePlugin extends Plugin {
@WorkerThread
private void receiveFile(NetworkPacket np) {
CompositeReceiveFileRunnable runnable;
CompositeReceiveFileJob job;
boolean hasNumberOfFiles = np.has(KEY_NUMBER_OF_FILES);
boolean hasOpen = np.has("open");
if (hasNumberOfFiles && !hasOpen && receiveFileRunnable != null) {
runnable = receiveFileRunnable;
if (hasNumberOfFiles && !hasOpen && receiveFileJob != null) {
job = receiveFileJob;
} else {
runnable = new CompositeReceiveFileRunnable(device, receiveFileRunnableCallback);
job = new CompositeReceiveFileJob(device, receiveFileJobCallback);
}
if (!hasNumberOfFiles) {
......@@ -216,13 +222,13 @@ public class SharePlugin extends Plugin {
np.set(KEY_TOTAL_PAYLOAD_SIZE, np.getPayloadSize());
}
runnable.addNetworkPacket(np);
job.addNetworkPacket(np);
if (runnable != receiveFileRunnable) {
if (job != receiveFileJob) {
if (hasNumberOfFiles && !hasOpen) {
receiveFileRunnable = runnable;
receiveFileJob = job;
}
executorService.execute(runnable);
backgroundJobHandler.runJob(job);
}
}
......@@ -232,7 +238,6 @@ public class SharePlugin extends Plugin {
}
void queuedSendUriList(final ArrayList<Uri> uriList) {
//Read all the data early, as we only have permissions to do it while the activity is alive
final ArrayList<NetworkPacket> toSend = new ArrayList<>();
for (Uri uri : uriList) {
......@@ -281,7 +286,6 @@ public class SharePlugin extends Plugin {
}
queuedSendUriList(uriList);
} catch (Exception e) {
Log.e("ShareActivity", "Exception");
e.printStackTrace();
......@@ -315,7 +319,6 @@ public class SharePlugin extends Plugin {
device.sendPacket(np);
}
}
}
@Override
......@@ -333,19 +336,32 @@ public class SharePlugin extends Plugin {
return new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE};
}
private class Callback implements CompositeReceiveFileRunnable.CallBack {
private class Callback implements CompositeReceiveFileJob.Callback<Void> {
@Override
public void onSuccess(CompositeReceiveFileRunnable runnable) {
if (runnable == receiveFileRunnable) {
receiveFileRunnable = null;
public void onResult(@NonNull BackgroundJob job, Void result) {
if (job == receiveFileJob) {
receiveFileJob = null;
}
}
@Override
public void onError(CompositeReceiveFileRunnable runnable, Throwable error) {
Log.e("SharePlugin", "onError() - " + error.getMessage());
if (runnable == receiveFileRunnable) {
receiveFileRunnable = null;
public void onError(@NonNull BackgroundJob job, @NonNull Throwable error) {
if (job == receiveFileJob) {
receiveFileJob = null;
}
}
}
void cancelJob(long jobId) {
if (backgroundJobHandler.isRunning(jobId)) {
BackgroundJob job = backgroundJobHandler.getJob(jobId);
if (job != null) {
job.cancel();
if (job == receiveFileJob) {
receiveFileJob = null;
}
}
}
}
......
/*
* Copyright 2018 Erik Duisters <e.duisters1@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package org.kde.kdeconnect.async;
import java.util.concurrent.atomic.AtomicLong;
import androidx.annotation.NonNull;
public abstract class BackgroundJob<I, R> implements Runnable {
private static AtomicLong atomicLong = new AtomicLong(0);
protected volatile boolean canceled;
private BackgroundJobHandler backgroundJobHandler;
private long id;
protected I requestInfo;
private Callback<R> callback;
public BackgroundJob(I requestInfo, Callback<R> 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<R> {
void onResult(@NonNull BackgroundJob job, R result);
void onError(@NonNull BackgroundJob job, @NonNull Throwable error);
}
protected void reportResult(R result) {
backgroundJobHandler.runOnUiThread(() -> {
callback.onResult(this, result);
backgroundJobHandler.onFinished(this);
});
}
protected void