Commit a3fb423d authored by Erik Duisters's avatar Erik Duisters

Upload files using a CompositeUploadFileJob making the upload cancelable

parent 3c97e1c0
......@@ -135,11 +135,13 @@
<item quantity="one">File: %1s</item>
<item quantity="other">(File %2$d of %3$d) : %1$s</item>
</plurals>
<string name="outgoing_file_title">Sending file to %1s</string>
<string name="outgoing_files_title">Sending files to %1s</string>
<plurals name="outgoing_file_title">
<item quantity="one">Sending %1$d file to %2$s</item>
<item quantity="other">Sending %1$d files to %2$s</item>
</plurals>
<plurals name="outgoing_files_text">
<item quantity="one">Sent %1$d file</item>
<item quantity="other">Sent %1$d out of %2$d files</item>
<item quantity="one">File: %1$s</item>
<item quantity="other">(File %2$d of %3$d) : %1$s</item>
</plurals>
<plurals name="received_files_title">
<item quantity="one">Received file from %1$s</item>
......@@ -149,12 +151,16 @@
<item quantity="one">Failed receiving file from %1$s</item>
<item quantity="other">Failed receiving %2$d of %3$d files from %1$s</item>
</plurals>
<plurals name="sent_files_title">
<item quantity="one">Sent file to %1$s</item>
<item quantity="other">Sent %2$d files to %1$s"</item>
</plurals>
<plurals name="send_files_fail_title">
<item quantity="one">Failed sending file to %1$s</item>
<item quantity="other">Failed sending %2$d of %3$d files to %1$s</item>
</plurals>
<string name="received_file_text">Tap to open \'%1s\'</string>
<string name="cannot_create_file">Cannot create file %s</string>
<string name="sent_file_title">Sent file to %1s</string>
<string name="sent_file_text">%1s</string>
<string name="sent_file_failed_title">Failed to send file to %1s</string>
<string name="sent_file_failed_text">%1s</string>
<string name="tap_to_answer">Tap to answer</string>
<string name="reconnect">Reconnect</string>
<string name="right_click">Send Right Click</string>
......
......@@ -201,7 +201,7 @@ public class LanLink extends BaseLink {
long size = np.getPayloadSize();
long progress = 0;
long timeSinceLastUpdate = -1;
while ((bytesRead = inputStream.read(buffer)) != -1) {
while (!np.isCanceled() && (bytesRead = inputStream.read(buffer)) != -1) {
//Log.e("ok",""+bytesRead);
progress += bytesRead;
outputStream.write(buffer, 0, bytesRead);
......@@ -223,7 +223,9 @@ public class LanLink extends BaseLink {
}
}
callback.onSuccess();
if (!np.isCanceled()) {
callback.onSuccess();
}
return true;
} catch (Exception e) {
if (callback != null) {
......
......@@ -57,6 +57,7 @@ public class NetworkPacket {
private JSONObject mBody;
private Payload mPayload;
private JSONObject mPayloadTransferInfo;
private volatile boolean canceled;
private NetworkPacket() {
......@@ -70,6 +71,9 @@ public class NetworkPacket {
mPayloadTransferInfo = new JSONObject();
}
public boolean isCanceled() { return canceled; }
public void cancel() { canceled = true; }
public String getType() {
return mType;
}
......@@ -317,7 +321,7 @@ public class NetworkPacket {
private Socket inputSocket;
private long payloadSize;
Payload(long payloadSize) {
public Payload(long payloadSize) {
this((InputStream)null, payloadSize);
}
......
......@@ -47,7 +47,7 @@ import androidx.core.content.FileProvider;
import androidx.documentfile.provider.DocumentFile;
public class CompositeReceiveFileJob extends BackgroundJob<Device, Void> {
private final ShareNotification shareNotification;
private final ReceiveNotification receiveNotification;
private NetworkPacket currentNetworkPacket;
private String currentFileName;
private int currentFileNum;
......@@ -66,8 +66,8 @@ public class CompositeReceiveFileJob extends BackgroundJob<Device, Void> {
lock = new Object();
networkPacketList = new ArrayList<>();
shareNotification = new ShareNotification(device);
shareNotification.addCancelAction(getId());
receiveNotification = new ReceiveNotification(device);
receiveNotification.addCancelAction(getId());
currentFileNum = 0;
totalNumFiles = 0;
totalPayloadSize = 0;
......@@ -87,7 +87,7 @@ public class CompositeReceiveFileJob extends BackgroundJob<Device, Void> {
this.totalNumFiles = numberOfFiles;
this.totalPayloadSize = totalPayloadSize;
shareNotification.setTitle(getDevice().getContext().getResources()
receiveNotification.setTitle(getDevice().getContext().getResources()
.getQuantityString(R.plurals.incoming_file_title, totalNumFiles, totalNumFiles, getDevice().getName()));
}
}
......@@ -100,7 +100,7 @@ public class CompositeReceiveFileJob extends BackgroundJob<Device, Void> {
totalNumFiles = networkPacket.getInt(SharePlugin.KEY_NUMBER_OF_FILES, 1);
totalPayloadSize = networkPacket.getLong(SharePlugin.KEY_TOTAL_PAYLOAD_SIZE);
shareNotification.setTitle(getDevice().getContext().getResources()
receiveNotification.setTitle(getDevice().getContext().getResources()
.getQuantityString(R.plurals.incoming_file_title, totalNumFiles, totalNumFiles, getDevice().getName()));
}
}
......@@ -149,6 +149,7 @@ public class CompositeReceiveFileJob extends BackgroundJob<Device, Void> {
publishFile(fileDocument, received);
}
} else {
//TODO: Only set progress to 100 if this is the only file/packet to send
setProgress(100);
publishFile(fileDocument, 0);
}
......@@ -180,7 +181,7 @@ public class CompositeReceiveFileJob extends BackgroundJob<Device, Void> {
isRunning = false;
if (canceled) {
shareNotification.cancel();
receiveNotification.cancel();
return;
}
......@@ -190,23 +191,23 @@ public class CompositeReceiveFileJob extends BackgroundJob<Device, Void> {
}
if (numFiles == 1 && currentNetworkPacket.has("open")) {
shareNotification.cancel();
receiveNotification.cancel();
openFile(fileDocument);
} else {
//Update the notification and allow to open the file from it
shareNotification.setFinished(getDevice().getContext().getResources().getQuantityString(R.plurals.received_files_title, numFiles, getDevice().getName(), numFiles));
receiveNotification.setFinished(getDevice().getContext().getResources().getQuantityString(R.plurals.received_files_title, numFiles, getDevice().getName(), numFiles));
if (totalNumFiles == 1 && fileDocument != null) {
shareNotification.setURI(fileDocument.getUri(), fileDocument.getType(), fileDocument.getName());
receiveNotification.setURI(fileDocument.getUri(), fileDocument.getType(), fileDocument.getName());
}
shareNotification.show();
receiveNotification.show();
}
reportResult(null);
} catch (ActivityNotFoundException e) {
shareNotification.setFinished(getDevice().getContext().getString(R.string.no_app_for_opening));
shareNotification.show();
receiveNotification.setFinished(getDevice().getContext().getString(R.string.no_app_for_opening));
receiveNotification.show();
} catch (Exception e) {
isRunning = false;
......@@ -217,8 +218,8 @@ public class CompositeReceiveFileJob extends BackgroundJob<Device, Void> {
failedFiles = (totalNumFiles - currentFileNum + 1);
}
shareNotification.setFinished(getDevice().getContext().getResources().getQuantityString(R.plurals.received_files_fail_title, failedFiles, getDevice().getName(), failedFiles, totalNumFiles));
shareNotification.show();
receiveNotification.setFinished(getDevice().getContext().getResources().getQuantityString(R.plurals.received_files_fail_title, failedFiles, getDevice().getName(), failedFiles, totalNumFiles));
receiveNotification.show();
reportError(e);
} finally {
closeAllInputStreams();
......@@ -238,7 +239,7 @@ public class CompositeReceiveFileJob extends BackgroundJob<Device, Void> {
//We need to check for already existing files only when storing in the default path.
//User-defined paths use the new Storage Access Framework that already handles this.
//If the file should be opened immediately store it in the standard location to avoid the FileProvider trouble (See ShareNotification::setURI)
//If the file should be opened immediately store it in the standard location to avoid the FileProvider trouble (See ReceiveNotification::setURI)
if (open || !ShareSettingsFragment.isCustomDestinationEnabled(getDevice().getContext())) {
final String defaultPath = ShareSettingsFragment.getDefaultDestinationDirectory().getAbsolutePath();
filenameToUse = FilesHelper.findNonExistingNameForNewFile(defaultPath, filenameToUse);
......@@ -300,10 +301,10 @@ public class CompositeReceiveFileJob extends BackgroundJob<Device, Void> {
private void setProgress(int progress) {
synchronized (lock) {
shareNotification.setProgress(progress, getDevice().getContext().getResources()
receiveNotification.setProgress(progress, getDevice().getContext().getResources()
.getQuantityString(R.plurals.incoming_files_text, totalNumFiles, currentFileName, currentFileNum, totalNumFiles));
}
shareNotification.show();
receiveNotification.show();
}
private void publishFile(DocumentFile fileDocument, long size) {
......
/*
* Copyright 2019 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 <https://www.gnu.org/licenses/>.
*/
package org.kde.kdeconnect.Plugins.SharePlugin;
import android.os.Handler;
import android.os.Looper;
import org.kde.kdeconnect.Device;
import org.kde.kdeconnect.NetworkPacket;
import org.kde.kdeconnect.async.BackgroundJob;
import org.kde.kdeconnect_tp.R;
import java.util.ArrayList;
import java.util.List;
import androidx.annotation.NonNull;
public class CompositeUploadFileJob extends BackgroundJob<Device, Void> {
private boolean isRunning;
private Handler handler;
private String currentFileName;
private int currentFileNum;
private boolean updatePacketPending;
private long totalSend;
private int prevProgressPercentage;
private UploadNotification uploadNotification;
private final Object lock; //Use to protect concurrent access to the variables below
private final List<NetworkPacket> networkPacketList;
private NetworkPacket currentNetworkPacket;
private final Device.SendPacketStatusCallback sendPacketStatusCallback;
private int totalNumFiles;
private long totalPayloadSize;
CompositeUploadFileJob(@NonNull Device device, @NonNull Callback<Void> callback) {
super(device, callback);
isRunning = false;
handler = new Handler(Looper.getMainLooper());
currentFileNum = 0;
currentFileName = "";
updatePacketPending = false;
lock = new Object();
networkPacketList = new ArrayList<>();
totalNumFiles = 0;
totalPayloadSize = 0;
totalSend = 0;
prevProgressPercentage = 0;
uploadNotification = new UploadNotification(getDevice());
uploadNotification.addCancelAction(getId());
sendPacketStatusCallback = new SendPacketStatusCallback();
}
private Device getDevice() { return requestInfo; }
@Override
public void run() {
boolean done;
isRunning = true;
synchronized (lock) {
done = networkPacketList.isEmpty();
}
try {
while (!done && !canceled) {
synchronized (lock) {
currentNetworkPacket = networkPacketList.remove(0);
}
currentFileName = currentNetworkPacket.getString("filename");
currentFileNum++;
setProgress(prevProgressPercentage);
addTotalsToNetworkPacket(currentNetworkPacket);
if (!getDevice().sendPacketBlocking(currentNetworkPacket, sendPacketStatusCallback)) {
throw new RuntimeException("Sending packet failed");
}
synchronized (lock) {
done = networkPacketList.isEmpty();
}
}
if (canceled) {
uploadNotification.cancel();
} else {
uploadNotification.setFinished(getDevice().getContext().getResources().getQuantityString(R.plurals.sent_files_title, currentFileNum, getDevice().getName(), currentFileNum));
uploadNotification.show();
reportResult(null);
}
} catch (RuntimeException e) {
int failedFiles;
synchronized (lock) {
failedFiles = (totalNumFiles - currentFileNum + 1);
uploadNotification.setFinished(getDevice().getContext().getResources()
.getQuantityString(R.plurals.send_files_fail_title, failedFiles, getDevice().getName(),
failedFiles, totalNumFiles));
}
uploadNotification.show();
reportError(e);
} finally {
isRunning = false;
for (NetworkPacket networkPacket : networkPacketList) {
networkPacket.getPayload().close();
}
networkPacketList.clear();
}
}
private void addTotalsToNetworkPacket(NetworkPacket networkPacket) {
synchronized (lock) {
networkPacket.set(SharePlugin.KEY_NUMBER_OF_FILES, totalNumFiles);
networkPacket.set(SharePlugin.KEY_TOTAL_PAYLOAD_SIZE, totalPayloadSize);
}
}
private void setProgress(int progress) {
synchronized (lock) {
uploadNotification.setProgress(progress, getDevice().getContext().getResources()
.getQuantityString(R.plurals.outgoing_files_text, totalNumFiles, currentFileName, currentFileNum, totalNumFiles));
}
uploadNotification.show();
}
void addNetworkPacket(@NonNull NetworkPacket networkPacket) {
synchronized (lock) {
networkPacketList.add(networkPacket);
totalNumFiles++;
if (networkPacket.getPayloadSize() >= 0) {
totalPayloadSize += networkPacket.getPayloadSize();
}
uploadNotification.setTitle(getDevice().getContext().getResources()
.getQuantityString(R.plurals.outgoing_file_title, totalNumFiles, totalNumFiles, getDevice().getName()));
//Give SharePlugin some time to add more NetworkPackets
if (isRunning && !updatePacketPending) {
updatePacketPending = true;
handler.post(this::sendUpdatePacket);
}
}
}
private void sendUpdatePacket() {
NetworkPacket np = new NetworkPacket(SharePlugin.PACKET_TYPE_SHARE_REQUEST_UPDATE);
synchronized (lock) {
np.set("numberOfFiles", totalNumFiles);
np.set("totalPayloadSize", totalPayloadSize);
updatePacketPending = false;
}
getDevice().sendPacket(np);
}
@Override
public void cancel() {
super.cancel();
currentNetworkPacket.cancel();
}
private class SendPacketStatusCallback extends Device.SendPacketStatusCallback {
@Override
public void onProgressChanged(int percent) {
float send = totalSend + (currentNetworkPacket.getPayloadSize() * ((float)percent / 100));
int progress = (int)((send * 100) / totalPayloadSize);
if (progress != prevProgressPercentage) {
setProgress(progress);
prevProgressPercentage = progress;
}
}
@Override
public void onSuccess() {
if (currentNetworkPacket.getPayloadSize() == 0) {
synchronized (lock) {
if (networkPacketList.isEmpty()) {
setProgress(100);
}
}
}
totalSend += currentNetworkPacket.getPayloadSize();
}
@Override
public void onFailure(Throwable e) {
//Ignored
}
}
}
package org.kde.kdeconnect.Plugins.SharePlugin;
import android.app.NotificationManager;
import android.content.Context;
import android.content.res.Resources;
import android.util.Log;
import org.kde.kdeconnect.Device;
import org.kde.kdeconnect.Helpers.NotificationHelper;
import org.kde.kdeconnect.NetworkPacket;
import org.kde.kdeconnect_tp.R;
import java.util.ArrayList;
import androidx.core.app.NotificationCompat;
class NotificationUpdateCallback extends Device.SendPacketStatusCallback {
private final Resources res;
private final Device device;
private final NotificationManager notificationManager;
private final NotificationCompat.Builder builder;
private final ArrayList<NetworkPacket> toSend;
private final int notificationId;
private int sentFiles = 0;
private final int numFiles;
NotificationUpdateCallback(Context context, Device device, ArrayList<NetworkPacket> toSend) {
this.toSend = toSend;
this.device = device;
this.res = context.getResources();
String title;
if (toSend.size() > 1) {
title = res.getString(R.string.outgoing_files_title, device.getName());
} else {
title = res.getString(R.string.outgoing_file_title, device.getName());
}
notificationManager = (NotificationManager)context.getSystemService(Context.NOTIFICATION_SERVICE);
builder = new NotificationCompat.Builder(context, NotificationHelper.Channels.FILETRANSFER)
.setSmallIcon(android.R.drawable.stat_sys_upload)
.setAutoCancel(true)
.setOngoing(true)
.setProgress(100, 0, false)
.setContentTitle(title)
.setTicker(title);
notificationId = (int) System.currentTimeMillis();
numFiles = toSend.size();
}
@Override
public void onProgressChanged(int progress) {
builder.setProgress(100 * numFiles, (100 * sentFiles) + progress, false);
NotificationHelper.notifyCompat(notificationManager, notificationId, builder.build());
}
@Override
public void onSuccess() {
sentFiles++;
if (sentFiles == numFiles) {
updateDone(true);
} else {
updateText();
}
NotificationHelper.notifyCompat(notificationManager, notificationId, builder.build());
}
@Override
public void onFailure(Throwable e) {
updateDone(false);
NotificationHelper.notifyCompat(notificationManager, notificationId, builder.build());
Log.e("KDEConnect", "Exception", e);
}
private void updateText() {
String text;
text = res.getQuantityString(R.plurals.outgoing_files_text, numFiles, sentFiles, numFiles);
builder.setContentText(text);
}
private void updateDone(boolean successful) {
int icon;
String title;
String text;
if (successful) {
if (numFiles > 1) {
text = res.getQuantityString(R.plurals.outgoing_files_text, numFiles, sentFiles, numFiles);
} else {
final String filename = toSend.get(0).getString("filename");
text = res.getString(R.string.sent_file_text, filename);
}
title = res.getString(R.string.sent_file_title, device.getName());
icon = android.R.drawable.stat_sys_upload_done;
} else {
final String filename = toSend.get(sentFiles).getString("filename");
title = res.getString(R.string.sent_file_failed_title, device.getName());
text = res.getString(R.string.sent_file_failed_text, filename);
icon = android.R.drawable.stat_notify_error;
}
builder.setOngoing(false)
.setTicker(title)
.setContentTitle(title)
.setContentText(text)
.setSmallIcon(icon)
.setProgress(0, 0, false); //setting progress to 0 out of 0 remove the progress bar
}
}
......@@ -43,7 +43,7 @@ import java.io.InputStream;
import androidx.core.app.NotificationCompat;
import androidx.core.content.FileProvider;
class ShareNotification {
class ReceiveNotification {
private final NotificationManager notificationManager;
private final int notificationId;
private NotificationCompat.Builder builder;
......@@ -54,7 +54,7 @@ class ShareNotification {
private static final int bigImageWidth = 1440;
private static final int bigImageHeight = 720;
public ShareNotification(Device device) {
public ReceiveNotification(Device device) {
this.device = device;
notificationId = (int) System.currentTimeMillis();
......
......@@ -89,7 +89,7 @@ public class SendFileActivity extends AppCompatActivity {
if (uris.isEmpty()) {
Log.w("SendFileActivity", "No files to send?");
} else {
BackgroundService.RunWithPlugin(this, mDeviceId, SharePlugin.class, plugin -> plugin.queuedSendUriList(uris));
BackgroundService.RunWithPlugin(this, mDeviceId, SharePlugin.class, plugin -> plugin.sendUriList(uris));
}
}
finish();
......
......@@ -56,7 +56,7 @@ public class SharePlugin extends Plugin {
final static String CANCEL_SHARE_BACKGROUND_JOB_ID_EXTRA = "backgroundJobId";
private final static String PACKET_TYPE_SHARE_REQUEST = "kdeconnect.share.request";
private final static String PACKET_TYPE_SHARE_REQUEST_UPDATE = "kdeconnect.share.request.update";
final static String PACKET_TYPE_SHARE_REQUEST_UPDATE = "kdeconnect.share.request.update";
final static String KEY_NUMBER_OF_FILES = "numberOfFiles";
final static String KEY_TOTAL_PAYLOAD_SIZE = "totalPayloadSize";
......@@ -65,6 +65,7 @@ public class SharePlugin extends Plugin {
private final Handler handler;
private CompositeReceiveFileJob receiveFileJob;
private CompositeUploadFileJob uploadFileJob;
private final Callback receiveFileJobCallback;
public SharePlugin() {
......@@ -147,7 +148,8 @@ public class SharePlugin extends Plugin {
}
} catch (Exception e) {
Log.e("SharePlugin", "Exception", e);
Log.e("SharePlugin", "Exception");
e.printStackTrace();
}
return true;
......@@ -204,36 +206,28 @@ public class SharePlugin extends Plugin {
return ShareSettingsFragment.newInstance(getPluginKey());
}
void queuedSendUriList(final ArrayList<Uri> uriList) {
void sendUriList(final ArrayList<Uri> uriList) {
CompositeUploadFileJob job = null;
if (uploadFileJob == null) {
job = new CompositeUploadFileJob(device, this.receiveFileJobCallback);
} else {
job = uploadFileJob;
}
//Read all the data early, as we only have permissions to do it while the activity is alive
final ArrayList<NetworkPacket> toSend = new ArrayList<>();
for (Uri uri : uriList) {
NetworkPacket np = FilesHelper.uriToNetworkPacket(context, uri, PACKET_TYPE_SHARE_REQUEST);
if (np != null) {
toSend.add(np);
job.addNetworkPacket(np);
}
}
//Callback that shows a progress notification
final NotificationUpdateCallback notificationUpdateCallback = new NotificationUpdateCallback(context, device, toSend);
//Do the sending in background
new Thread(() -> {
//Actually send the files
try {
for (NetworkPacket np : toSend) {
boolean success = device.sendPacketBlocking(np, notificationUpdateCallback);
if (!success) {
Log.e("SharePlugin", "Error sending files");
return;
}
}
} catch (Exception e) {
Log.e("SharePlugin", "Error sending files", e);
}
}).start();
if (job != uploadFileJob) {
uploadFileJob = job;
backgroundJobHandler.runJob(uploadFileJob);
}
}
public void share(Intent intent) {
......@@ -252,9 +246,10 @@ public class SharePlugin extends Plugin {
uriList.add(uri);
}
queuedSendUriList(uriList);
sendUriList(uriList);
} catch (Exception e) {
Log.e("ShareActivity", "Exception", e);
Log.e("ShareActivity", "Exception");
e.printStackTrace();
}
} else if (extras.containsKey(Intent.EXTRA_TEXT)) {
......@@ -302,11 +297,13 @@ public class SharePlugin extends Plugin {
return new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE};
}
private class Callback implements CompositeReceiveFileJob.Callback<Void> {
private class Callback implements BackgroundJob.Callback<Void> {
@Override
public void onResult(@NonNull BackgroundJob job, Void result) {
if (job == receiveFileJob) {
receiveFileJob = null;
} else if (job == uploadFileJob) {
uploadFileJob = null;
}
}
......@@ -314,6 +311,8 @@ public class SharePlugin extends Plugin {
public void onError(@NonNull BackgroundJob job, @NonNull Throwable error) {
if (job == receiveFileJob) {
receiveFileJob = null;
} else if (job == uploadFileJob) {
uploadFileJob = null;
}
}
}
......@@ -327,6 +326,8 @@ public class SharePlugin extends Plugin {
if (job == receiveFileJob) {
receiveFileJob = null;
} else if (job == uploadFileJob) {
uploadFileJob = null;