diff --git a/res/values/strings.xml b/res/values/strings.xml
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -127,16 +127,28 @@
Pairing request from %1s
Received link from %1s
Tap to open \'%1s\'
- Incoming file from %1s
- %1s
+ Receiving file from %1s>
+ - Receiving %1$d file from %2$s
+ - Receiving %1$d files from %2$s
+
+
+ - File: %1s
+ - (File %2$d of %3$d) : %1$s
+
Sending file to %1s
Sending files to %1s
- Sent %1$d file
- Sent %1$d out of %2$d files
- Received file from %1s
- Failed receiving file from %1s
+
+ - Received file from %2$s
+ - Received %1$d files from %2$s
+
+
+ - Failed receiving file from %3$s
+ - Failed receiving %1$d of %2$d files from %3$s
+
Tap to open \'%1s\'
Sent file to %1s
%1s
diff --git a/src/org/kde/kdeconnect/Plugins/SharePlugin/ReceiveFileRunnable.java b/src/org/kde/kdeconnect/Plugins/SharePlugin/ReceiveFileRunnable.java
new file mode 100644
--- /dev/null
+++ b/src/org/kde/kdeconnect/Plugins/SharePlugin/ReceiveFileRunnable.java
@@ -0,0 +1,92 @@
+/*
+ * 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.os.Handler;
+import android.os.Looper;
+
+import java.io.IOException;
+
+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);
+
+ while ((count = info.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 {
+ try {
+ info.inputStream.close();
+ } catch (IOException e) {
+ }
+ try {
+ info.outputStream.close();
+ } catch (IOException e) {
+ }
+ }
+ }
+}
diff --git a/src/org/kde/kdeconnect/Plugins/SharePlugin/ShareInfo.java b/src/org/kde/kdeconnect/Plugins/SharePlugin/ShareInfo.java
new file mode 100644
--- /dev/null
+++ b/src/org/kde/kdeconnect/Plugins/SharePlugin/ShareInfo.java
@@ -0,0 +1,64 @@
+/*
+ * 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.support.v4.provider.DocumentFile;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+
+class ShareInfo {
+ String fileName;
+ long fileSize;
+ int currentFileNumber;
+ DocumentFile fileDocument;
+ InputStream inputStream;
+ OutputStream outputStream;
+ boolean shouldOpen;
+
+ private final Object lock = new Object(); // To protect access to numberOfFiles and totalTransferSize
+ private int numberOfFiles;
+ private long totalTransferSize;
+
+ int numberOfFiles() {
+ synchronized (lock) {
+ return numberOfFiles;
+ }
+ }
+
+ void setNumberOfFiles(int numberOfFiles) {
+ synchronized (lock) {
+ this.numberOfFiles = numberOfFiles;
+ }
+ }
+
+ long totalTransferSize() {
+ synchronized (lock) {
+ return totalTransferSize;
+ }
+ }
+
+ void setTotalTransferSize(long totalTransferSize) {
+ synchronized (lock) {
+ this.totalTransferSize = totalTransferSize;
+ }
+ }
+}
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
@@ -42,8 +42,8 @@
import java.io.IOException;
import java.io.InputStream;
+//TODO: Starting API 24 notification title and text are both displayed on 1 line above the progress bar. Because title can be long, the text is often not displayed
class ShareNotification {
- private final String filename;
private final NotificationManager notificationManager;
private final int notificationId;
private NotificationCompat.Builder builder;
@@ -53,15 +53,12 @@
private static final int bigImageWidth = 1440;
private static final int bigImageHeight = 720;
- public ShareNotification(Device device, String filename) {
+ public ShareNotification(Device device) {
this.device = device;
- this.filename = filename;
+
notificationId = (int) System.currentTimeMillis();
notificationManager = (NotificationManager) device.getContext().getSystemService(Context.NOTIFICATION_SERVICE);
builder = new NotificationCompat.Builder(device.getContext(), NotificationHelper.Channels.FILETRANSFER)
- .setContentTitle(device.getContext().getResources().getString(R.string.incoming_file_title, device.getName()))
- .setContentText(device.getContext().getResources().getString(R.string.incoming_file_text, filename))
- .setTicker(device.getContext().getResources().getString(R.string.incoming_file_title, device.getName()))
.setSmallIcon(android.R.drawable.stat_sys_download)
.setAutoCancel(true)
.setOngoing(true)
@@ -80,13 +77,17 @@
return notificationId;
}
- public void setProgress(int progress) {
- builder.setProgress(100, progress, false)
- .setContentTitle(device.getContext().getResources().getString(R.string.incoming_file_title, device.getName()) + " (" + progress + "%)");
+ public void setTitle(String title) {
+ builder.setContentTitle(title);
+ builder.setTicker(title);
+ }
+
+ public void setProgress(int progress, String progressMessage) {
+ builder.setProgress( 100, progress, false);
+ builder.setContentText(progressMessage);
}
- public void setFinished(boolean success) {
- String message = success ? device.getContext().getResources().getString(R.string.received_file_title, device.getName()) : device.getContext().getResources().getString(R.string.received_file_fail_title, device.getName());
+ public void setFinished(String message) {
builder = new NotificationCompat.Builder(device.getContext(), NotificationHelper.Channels.DEFAULT);
builder.setContentTitle(message)
.setTicker(message)
@@ -100,7 +101,7 @@
}
}
- public void setURI(Uri destinationUri, String mimeType) {
+ public void setURI(Uri destinationUri, String mimeType, String filename) {
/*
* We only support file URIs (because sending a content uri to another app does not work for security reasons).
* In effect, that means only the default download folder currently works.
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
@@ -36,35 +36,49 @@
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
import android.provider.MediaStore;
+import android.support.annotation.WorkerThread;
import android.support.v4.app.NotificationCompat;
import android.support.v4.content.ContextCompat;
import android.support.v4.content.FileProvider;
import android.support.v4.provider.DocumentFile;
import android.util.Log;
import android.widget.Toast;
-import org.kde.kdeconnect.Device;
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.DeviceSettingsActivity;
import org.kde.kdeconnect_tp.R;
+import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.InputStream;
-import java.io.OutputStream;
import java.net.URL;
import java.util.ArrayList;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
-public class SharePlugin extends Plugin {
+public class SharePlugin extends Plugin implements ReceiveFileRunnable.CallBack {
private final static String PACKET_TYPE_SHARE_REQUEST = "kdeconnect.share.request";
private final static boolean openUrlsDirectly = true;
+ private ShareNotification shareNotification;
+ private FinishReceivingRunnable finishReceivingRunnable;
+ private ExecutorService executorService;
+ private ShareInfo currentShareInfo;
+ private Handler handler;
+
+ public SharePlugin() {
+ executorService = Executors.newSingleThreadExecutor();
+ handler = new Handler(Looper.getMainLooper());
+ }
@Override
public boolean onCreate() {
@@ -110,13 +124,10 @@
}
@Override
+ @WorkerThread
public boolean onPacketReceived(NetworkPacket np) {
-
try {
if (np.hasPayload()) {
-
- Log.i("SharePlugin", "hasPayload");
-
if (isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
receiveFile(np);
} else {
@@ -183,121 +194,72 @@
Toast.makeText(context, R.string.shareplugin_text_saved, Toast.LENGTH_LONG).show();
}
+ @WorkerThread
private void receiveFile(NetworkPacket np) {
+ if (finishReceivingRunnable != null) {
+ Log.i("SharePlugin", "receiveFile: canceling finishReceivingRunnable");
+ handler.removeCallbacks(finishReceivingRunnable);
+ finishReceivingRunnable = null;
+ }
- final InputStream input = np.getPayload();
- final long fileLength = np.getPayloadSize();
- final String originalFilename = np.getString("filename", Long.toString(System.currentTimeMillis()));
+ ShareInfo info = new ShareInfo();
+ info.currentFileNumber = currentShareInfo == null ? 1 : currentShareInfo.currentFileNumber + 1;
+ info.inputStream = 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());
+ }
+ }
- String filename = originalFilename;
+ 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") || !ShareSettingsActivity.isCustomDestinationEnabled(context)) {
final String defaultPath = ShareSettingsActivity.getDefaultDestinationDirectory().getAbsolutePath();
- filename = FilesHelper.findNonExistingNameForNewFile(defaultPath, originalFilename);
+ filename = FilesHelper.findNonExistingNameForNewFile(defaultPath, filename);
destinationFolderDocument = DocumentFile.fromFile(new File(defaultPath));
} else {
destinationFolderDocument = ShareSettingsActivity.getDestinationDirectory(context);
}
String displayName = FilesHelper.getFileNameWithoutExt(filename);
- final String mimeType = FilesHelper.getMimeTypeFromFile(filename);
+ String mimeType = FilesHelper.getMimeTypeFromFile(filename);
if ("*/*".equals(mimeType)) {
displayName = filename;
}
- final DocumentFile destinationDocument = destinationFolderDocument.createFile(mimeType, displayName);
- final OutputStream destinationOutput;
+ info.fileDocument = destinationFolderDocument.createFile(mimeType, displayName);
+ assert info.fileDocument != null;
+ info.fileDocument.getType();
try {
- destinationOutput = context.getContentResolver().openOutputStream(destinationDocument.getUri());
+ info.outputStream = new BufferedOutputStream(context.getContentResolver().openOutputStream(info.fileDocument.getUri()));
} catch (FileNotFoundException e) {
e.printStackTrace();
return;
}
- final Uri destinationUri = destinationDocument.getUri();
-
- 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");
- }
-
- destinationOutput.flush();
-
- Log.i("SharePlugin", "Transfer finished: " + destinationUri.getPath());
-
- if (np.getBoolean("open")) {
- notification.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(destinationUri.getPath());
- Uri contentUri = FileProvider.getUriForFile(device.getContext(), "org.kde.kdeconnect_tp.fileprovider", file);
- intent.setDataAndType(contentUri, mimeType);
- intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
- } else {
- intent.setDataAndType(destinationUri, mimeType);
- }
-
- context.startActivity(intent);
- } else {
+ if (shareNotification == null) {
+ shareNotification = new ShareNotification(device);
+ }
- //Update the notification and allow to open the file from it
- notification.setFinished(true);
- notification.setURI(destinationUri, mimeType);
- notification.show();
+ shareNotification.setTitle(context.getResources().getQuantityString(R.plurals.incoming_file_title, info.numberOfFiles(), info.numberOfFiles(), device.getName()));
+ //shareNotification.setProgress(0, context.getResources().getQuantityString(R.plurals.incoming_files_text, numFiles, filename, currentFileNum, numFiles));
+ shareNotification.show();
- if (!ShareSettingsActivity.isCustomDestinationEnabled(context)) {
- 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);
- } 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();
- notification.setFinished(false);
- notification.show();
- } finally {
- try {
- destinationOutput.close();
- } catch (Exception e) {
- }
- try {
- input.close();
- } catch (Exception e) {
- }
- }
- }).start();
+ ReceiveFileRunnable runnable = new ReceiveFileRunnable(info, this);
+ executorService.execute(runnable);
}
@Override
@@ -482,4 +444,88 @@
public String[] getOptionalPermissions() {
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 (!ShareSettingsActivity.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);
+ }
+ }
+ }
+
+ @Override
+ public void onError(ShareInfo info, Throwable error) {
+ Log.e("SharePlugin", "onError: " + error.getMessage());
+
+ info.fileDocument.delete();
+
+ int failedFiles = info.numberOfFiles() - (info.currentFileNumber - 1);
+ shareNotification.setFinished(context.getResources().getQuantityString(R.plurals.received_files_fail_title, failedFiles, failedFiles, info.numberOfFiles(), device.getName()));
+ shareNotification.show();
+ shareNotification = null;
+ }
+
+ private class FinishReceivingRunnable implements Runnable {
+ private final ShareInfo info;
+
+ private FinishReceivingRunnable(ShareInfo info) {
+ this.info = info;
+ }
+
+ @Override
+ public void run() {
+ 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(), info.numberOfFiles(), device.getName()));
+
+ if (info.numberOfFiles() == 1) {
+ shareNotification.setURI(info.fileDocument.getUri(), info.fileDocument.getType(), info.fileName);
+ }
+
+ shareNotification.show();
+ Log.i("SharePlugin", "FinishReceivingRunnable: Setting shareNotification to null");
+ shareNotification = null;
+ finishReceivingRunnable = null;
+ }
+ }
+ }
}