Commit 4c6b74a2 authored by Erik Duisters's avatar Erik Duisters

Simplify receiving multiple files using only 1 notification

Summary:
Sequentially receiving multiple files was overly complicated sometimes leading to a progress
notification being shown indefinitely. When files are shared using an older kdeconnect-kde version
fallback to receiving each file individually

Test Plan:
Send multiple files from desktop to android and observe that android receives the files
sequentially using only 1 notification

Send multiple files from an kdeconnect-kde installation and observe that android receives the
files in parallel using multiple notifications

Reviewers: #kde_connect, nicolasfella, albertvaka

Reviewed By: #kde_connect, albertvaka

Subscribers: albertvaka, nicolasfella, kdeconnect

Tags: #kde_connect

Differential Revision: https://phabricator.kde.org/D17627
parent 5608fe12
/*
* 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.app.DownloadManager;
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_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.core.content.FileProvider;
import androidx.documentfile.provider.DocumentFile;
public class CompositeReceiveFileRunnable implements Runnable {
interface CallBack {
void onSuccess(CompositeReceiveFileRunnable runnable);
void onError(CompositeReceiveFileRunnable runnable, Throwable error);
}
private final Device device;
private final ShareNotification shareNotification;
private NetworkPacket currentNetworkPacket;
private String currentFileName;
private int currentFileNum;
private long totalReceived;
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;
CompositeReceiveFileRunnable(Device device, CallBack callBack) {
this.device = device;
this.callBack = callBack;
lock = new Object();
networkPacketList = new ArrayList<>();
shareNotification = new ShareNotification(device);
currentFileNum = 0;
totalNumFiles = 0;
totalPayloadSize = 0;
totalReceived = 0;
lastProgressTimeMillis = 0;
prevProgressPercentage = 0;
handler = new Handler(Looper.getMainLooper());
}
void addNetworkPacket(NetworkPacket networkPacket) {
if (!networkPacketList.contains(networkPacket)) {
synchronized (lock) {
networkPacketList.add(networkPacket);
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()));
}
}
}
@Override
public void run() {
boolean done;
OutputStream outputStream = null;
synchronized (lock) {
done = networkPacketList.isEmpty();
}
try {
DocumentFile fileDocument = null;
while (!done) {
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(device.getContext().getContentResolver().openOutputStream(fileDocument.getUri()));
InputStream inputStream = currentNetworkPacket.getPayload().getInputStream();
long received = receiveFile(inputStream, outputStream);
currentNetworkPacket.getPayload().close();
if ( received != currentNetworkPacket.getPayloadSize()) {
fileDocument.delete();
throw new RuntimeException("Failed to receive: " + currentFileName + " received:" + received + " bytes, expected: " + currentNetworkPacket.getPayloadSize() + " bytes");
} else {
publishFile(fileDocument, received);
}
} else {
setProgress(100);
publishFile(fileDocument, 0);
}
boolean listIsEmpty;
synchronized (lock) {
networkPacketList.remove(0);
listIsEmpty = networkPacketList.isEmpty();
}
if (listIsEmpty) {
try {
Thread.sleep(250);
} 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();
}
}
int numFiles;
synchronized (lock) {
numFiles = totalNumFiles;
}
if (numFiles == 1 && currentNetworkPacket.has("open")) {
shareNotification.cancel();
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));
if (totalNumFiles == 1 && fileDocument != null) {
shareNotification.setURI(fileDocument.getUri(), fileDocument.getType(), fileDocument.getName());
}
shareNotification.show();
}
handler.post(() -> callBack.onSuccess(this));
} catch (Exception e) {
int failedFiles;
synchronized (lock) {
failedFiles = (totalNumFiles - currentFileNum + 1);
}
shareNotification.setFinished(device.getContext().getResources().getQuantityString(R.plurals.received_files_fail_title, failedFiles, device.getName(), failedFiles, totalNumFiles));
shareNotification.show();
handler.post(() -> callBack.onError(this, 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 ShareNotification::setURI)
if (open || !ShareSettingsFragment.isCustomDestinationEnabled(device.getContext())) {
final String defaultPath = ShareSettingsFragment.getDefaultDestinationDirectory().getAbsolutePath();
filenameToUse = FilesHelper.findNonExistingNameForNewFile(defaultPath, filenameToUse);
destinationFolderDocument = DocumentFile.fromFile(new File(defaultPath));
} else {
destinationFolderDocument = ShareSettingsFragment.getDestinationDirectory(device.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(device.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) {
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) {
shareNotification.setProgress(progress, device.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())) {
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);
} else {
//Make sure it is added to the Android Gallery anyway
Log.i("SharePlugin", "Adding to gallery");
MediaStoreHelper.indexFile(device.getContext(), fileDocument.getUri());
}
}
private void openFile(DocumentFile fileDocument) {
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(device.getContext(), "org.kde.kdeconnect_tp.fileprovider", file);
intent.setDataAndType(contentUri, fileDocument.getType());
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
} else {
intent.setDataAndType(fileDocument.getUri(), fileDocument.getType());
}
device.getContext().startActivity(intent);
}
}
/*
* 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.os.Handler;
import android.os.Looper;
import java.io.IOException;
import java.io.InputStream;
public class ReceiveFileRunnable implements Runnable {
interface CallBack {
void onProgress(ShareInfo info, int progress);
void onSuccess(ShareInfo info);
void onError(ShareInfo info, Throwable error);
}
private final ShareInfo info;
private final CallBack callBack;
private final Handler handler;
ReceiveFileRunnable(ShareInfo info, CallBack callBack) {
this.info = info;
this.callBack = callBack;
this.handler = new Handler(Looper.getMainLooper());
}
@Override
public void run() {
try {
byte data[] = new byte[4096];
long received = 0, prevProgressPercentage = 0;
int count;
callBack.onProgress(info, 0);
InputStream inputStream = info.payload.getInputStream();
while ((count = inputStream.read(data)) >= 0) {
received += count;
if (received > info.fileSize) {
break;
}
info.outputStream.write(data, 0, count);
if (info.fileSize > 0) {
long progressPercentage = (received * 100 / info.fileSize);
if (progressPercentage != prevProgressPercentage) {
prevProgressPercentage = progressPercentage;
handler.post(() -> callBack.onProgress(info, (int)progressPercentage));
}
}
//else Log.e("SharePlugin", "Infinite loop? :D");
}
info.outputStream.flush();
if (received != info.fileSize) {
throw new RuntimeException("Received:" + received + " bytes, expected: " + info.fileSize + " bytes");
}
handler.post(() -> callBack.onSuccess(info));
} catch (IOException e) {
handler.post(() -> callBack.onError(info, e));
} finally {
info.payload.close();
try {
info.outputStream.close();
} catch (IOException ignored) {}
}
}
}
......@@ -22,7 +22,6 @@ package org.kde.kdeconnect.Plugins.SharePlugin;
import android.Manifest;
import android.app.Activity;
import android.app.DownloadManager;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
......@@ -34,7 +33,6 @@ import android.content.res.Resources;
import android.database.Cursor;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
......@@ -42,17 +40,13 @@ import android.provider.MediaStore;
import android.util.Log;
import android.widget.Toast;
import org.kde.kdeconnect.Helpers.FilesHelper;
import org.kde.kdeconnect.Helpers.MediaStoreHelper;
import org.kde.kdeconnect.Helpers.NotificationHelper;
import org.kde.kdeconnect.NetworkPacket;
import org.kde.kdeconnect.Plugins.Plugin;
import org.kde.kdeconnect.UserInterface.PluginSettingsFragment;
import org.kde.kdeconnect_tp.R;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.InputStream;
import java.net.URL;
import java.util.ArrayList;
......@@ -62,23 +56,23 @@ import java.util.concurrent.Executors;
import androidx.annotation.WorkerThread;
import androidx.core.app.NotificationCompat;
import androidx.core.content.ContextCompat;
import androidx.core.content.FileProvider;
import androidx.documentfile.provider.DocumentFile;
public class SharePlugin extends Plugin implements ReceiveFileRunnable.CallBack {
public class SharePlugin extends Plugin {
private final static String PACKET_TYPE_SHARE_REQUEST = "kdeconnect.share.request";
final static String KEY_NUMBER_OF_FILES = "numberOfFiles";
final static String KEY_TOTAL_PAYLOAD_SIZE = "totalPayloadSize";
private final static boolean openUrlsDirectly = true;
private ShareNotification shareNotification;
private FinishReceivingRunnable finishReceivingRunnable;
private ExecutorService executorService;
private ShareInfo currentShareInfo;
private Handler handler;
private final Handler handler;
CompositeReceiveFileRunnable receiveFileRunnable;
private final Callback receiveFileRunnableCallback;
public SharePlugin() {
executorService = Executors.newSingleThreadExecutor();
executorService = Executors.newFixedThreadPool(5);
handler = new Handler(Looper.getMainLooper());
receiveFileRunnableCallback = new Callback();
}
@Override
......@@ -197,78 +191,29 @@ public class SharePlugin extends Plugin implements ReceiveFileRunnable.CallBack
@WorkerThread
private void receiveFile(NetworkPacket np) {
if (finishReceivingRunnable != null) {
Log.i("SharePlugin", "receiveFile: canceling finishReceivingRunnable");
handler.removeCallbacks(finishReceivingRunnable);
finishReceivingRunnable = null;
}
CompositeReceiveFileRunnable runnable;
ShareInfo info = new ShareInfo();
info.currentFileNumber = currentShareInfo == null ? 1 : currentShareInfo.currentFileNumber + 1;
info.payload = np.getPayload();
info.fileSize = np.getPayloadSize();
info.fileName = np.getString("filename", Long.toString(System.currentTimeMillis()));
info.shouldOpen = np.getBoolean("open");
info.setNumberOfFiles(np.getInt("numberOfFiles", 1));
info.setTotalTransferSize(np.getLong("totalPayloadSize", 1));
if (currentShareInfo == null) {
currentShareInfo = info;
} else {
synchronized (currentShareInfo) {
currentShareInfo.setNumberOfFiles(info.numberOfFiles());
currentShareInfo.setTotalTransferSize(info.totalTransferSize());
}
}
boolean hasNumberOfFiles = np.has(KEY_NUMBER_OF_FILES);
boolean hasOpen = np.has("open");
String filename = info.fileName;
final DocumentFile destinationFolderDocument;
//We need to check for already existing files only when storing in the default path.
//User-defined paths use the new Storage Access Framework that already handles this.
//If the file should be opened immediately store it in the standard location to avoid the FileProvider trouble (See ShareNotification::setURI)
if (np.getBoolean("open") || !ShareSettingsFragment.isCustomDestinationEnabled(context)) {
final String defaultPath = ShareSettingsFragment.getDefaultDestinationDirectory().getAbsolutePath();
filename = FilesHelper.findNonExistingNameForNewFile(defaultPath, filename);
destinationFolderDocument = DocumentFile.fromFile(new File(defaultPath));
if (hasNumberOfFiles && !hasOpen && receiveFileRunnable != null) {
runnable = receiveFileRunnable;
} else {
destinationFolderDocument = ShareSettingsFragment.getDestinationDirectory(context);
}
String displayName = FilesHelper.getFileNameWithoutExt(filename);
String mimeType = FilesHelper.getMimeTypeFromFile(filename);
if ("*/*".equals(mimeType)) {
displayName = filename;
}
info.fileDocument = destinationFolderDocument.createFile(mimeType, displayName);
assert info.fileDocument != null;
if (shareNotification == null) {
shareNotification = new ShareNotification(device);
runnable = new CompositeReceiveFileRunnable(device, receiveFileRunnableCallback);
}
if (info.fileDocument == null) {
onError(info, new RuntimeException(context.getString(R.string.cannot_create_file, filename)));
return;
if (!hasNumberOfFiles) {
np.set(KEY_NUMBER_OF_FILES, 1);
np.set(KEY_TOTAL_PAYLOAD_SIZE, np.getPayloadSize());
}
shareNotification.setTitle(context.getResources().getQuantityString(R.plurals.incoming_file_title, info.numberOfFiles(), info.numberOfFiles(), device.getName()));
shareNotification.show();
runnable.addNetworkPacket(np);
if (np.hasPayload()) {
try {
info.outputStream = new BufferedOutputStream(context.getContentResolver().openOutputStream(info.fileDocument.getUri()));
} catch (FileNotFoundException e) {
e.printStackTrace();
return;
if (runnable != receiveFileRunnable) {
if (hasNumberOfFiles && !hasOpen) {
receiveFileRunnable = runnable;
}
ReceiveFileRunnable runnable = new ReceiveFileRunnable(info, this);
executorService.execute(runnable);
} else {
onProgress(info, 100);
onSuccess(info);
}
}
......@@ -456,92 +401,20 @@ public class SharePlugin extends Plugin implements ReceiveFileRunnable.CallBack
return new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE};
}
@Override
public void onProgress(ShareInfo info, int progress) {
if (progress == 0 && currentShareInfo != info) {
currentShareInfo = info;
}
shareNotification.setProgress(progress, context.getResources().getQuantityString(R.plurals.incoming_files_text, info.numberOfFiles(), info.fileName, info.currentFileNumber, info.numberOfFiles()));
shareNotification.show();
}
@Override
public void onSuccess(ShareInfo info) {
Log.i("SharePlugin", "onSuccess() - Transfer finished for file: " + info.fileDocument.getUri().getPath());
if (info.shouldOpen) {
shareNotification.cancel();
Intent intent = new Intent(Intent.ACTION_VIEW);
if (Build.VERSION.SDK_INT >= 24) {
//Nougat and later require "content://" uris instead of "file://" uris
File file = new File(info.fileDocument.getUri().getPath());
Uri contentUri = FileProvider.getUriForFile(device.getContext(), "org.kde.kdeconnect_tp.fileprovider", file);
intent.setDataAndType(contentUri, info.fileDocument.getType());
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
} else {
intent.setDataAndType(info.fileDocument.getUri(), info.fileDocument.getType());
}
context.startActivity(intent);
} else {
if (!ShareSettingsFragment.isCustomDestinationEnabled(context)) {
Log.i("SharePlugin", "Adding to downloads");
DownloadManager manager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE);
manager.addCompletedDownload(info.fileDocument.getUri().getLastPathSegment(), device.getName(), true, info.fileDocument.getType(), info.fileDocument.getUri().getPath(), info.fileSize, false);
} else {
//Make sure it is added to the Android Gallery anyway
MediaStoreHelper.indexFile(context, info.fileDocument.getUri());
}
if (info.numberOfFiles() == 1 || info.currentFileNumber == info.numberOfFiles()) {
finishReceivingRunnable = new FinishReceivingRunnable(info);
Log.i("SharePlugin", "onSuccess() - scheduling finishReceivingRunnable");
handler.postDelayed(finishReceivingRunnable, 1000);
private class Callback implements CompositeReceiveFileRunnable.CallBack {
@Override
public void onSuccess(CompositeReceiveFileRunnable runnable) {
if (runnable == receiveFileRunnable) {
receiveFileRunnable = null;
}
}
}
@Override
public void onError(ShareInfo info, Throwable error) {
Log.e("SharePlugin", "onError: " + error.getMessage());
info.fileDocument.delete();
//TODO: Show error in notification
int failedFiles = info.numberOfFiles() - (info.currentFileNumber - 1);
shareNotification.setFinished(context.getResources().getQuantityString(R.plurals.received_files_fail_title, failedFiles, device.getName(), failedFiles, info.numberOfFiles()));
shareNotification.show();
shareNotification = null;
currentShareInfo = null;
}
private class FinishReceivingRunnable implements Runnable {
private final ShareInfo info;
private FinishReceivingRunnable(ShareInfo info) {
this.info = info;
}
@Override
public void run() {
Log.i("SharePlugin", "FinishReceivingRunnable: Finishing up");
if (shareNotification != null) {
//Update the notification and allow to open the file from it
shareNotification.setFinished(context.getResources().getQuantityString(R.plurals.received_files_title, info.numberOfFiles(), device.getName(), info.numberOfFiles()));
if (info.numberOfFiles() == 1) {
shareNotification.setURI(info.fileDocument.getUri(), info.fileDocument.getType(), info.fileName);
}
shareNotification.show();
shareNotification = null;
public void onError(CompositeReceiveFileRunnable runnable, Throwable error) {
Log.e("SharePlugin", "onError() - " + error.getMessage());
if (runnable == receiveFileRunnable) {
receiveFileRunnable = null;
}
finishReceivingRunnable = null;
currentShareInfo = null;
}
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment