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; + } + } + } }