diff --git a/src/org/kde/kdeconnect/Plugins/SharePlugin/CompositeReceiveFileJob.java b/src/org/kde/kdeconnect/Plugins/SharePlugin/CompositeReceiveFileJob.java index 98517eca..b94fff72 100644 --- a/src/org/kde/kdeconnect/Plugins/SharePlugin/CompositeReceiveFileJob.java +++ b/src/org/kde/kdeconnect/Plugins/SharePlugin/CompositeReceiveFileJob.java @@ -1,337 +1,336 @@ /* * 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.app.DownloadManager; import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; import android.net.Uri; import android.os.Build; 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; 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 CompositeReceiveFileJob extends BackgroundJob { private final ReceiveNotification receiveNotification; private NetworkPacket currentNetworkPacket; private String currentFileName; private int currentFileNum; private long totalReceived; private long lastProgressTimeMillis; private long prevProgressPercentage; private final Object lock; //Use to protect concurrent access to the variables below private final List networkPacketList; private int totalNumFiles; private long totalPayloadSize; private boolean isRunning; CompositeReceiveFileJob(Device device, BackgroundJob.Callback callBack) { super(device, callBack); lock = new Object(); networkPacketList = new ArrayList<>(); - receiveNotification = new ReceiveNotification(device); - receiveNotification.addCancelAction(getId()); + receiveNotification = new ReceiveNotification(device, getId()); currentFileNum = 0; totalNumFiles = 0; totalPayloadSize = 0; totalReceived = 0; lastProgressTimeMillis = 0; prevProgressPercentage = 0; } private Device getDevice() { return requestInfo; } boolean isRunning() { return isRunning; } void updateTotals(int numberOfFiles, long totalPayloadSize) { synchronized (lock) { this.totalNumFiles = numberOfFiles; this.totalPayloadSize = totalPayloadSize; receiveNotification.setTitle(getDevice().getContext().getResources() .getQuantityString(R.plurals.incoming_file_title, totalNumFiles, totalNumFiles, getDevice().getName())); } } void addNetworkPacket(NetworkPacket networkPacket) { synchronized (lock) { if (!networkPacketList.contains(networkPacket)) { networkPacketList.add(networkPacket); totalNumFiles = networkPacket.getInt(SharePlugin.KEY_NUMBER_OF_FILES, 1); totalPayloadSize = networkPacket.getLong(SharePlugin.KEY_TOTAL_PAYLOAD_SIZE); receiveNotification.setTitle(getDevice().getContext().getResources() .getQuantityString(R.plurals.incoming_file_title, totalNumFiles, totalNumFiles, getDevice().getName())); } } } @Override public void run() { boolean done; OutputStream outputStream = null; synchronized (lock) { done = networkPacketList.isEmpty(); } try { DocumentFile fileDocument = null; isRunning = true; while (!done && !canceled) { 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(getDevice().getContext().getContentResolver().openOutputStream(fileDocument.getUri())); InputStream inputStream = currentNetworkPacket.getPayload().getInputStream(); long received = receiveFile(inputStream, outputStream); currentNetworkPacket.getPayload().close(); if ( received != currentNetworkPacket.getPayloadSize()) { fileDocument.delete(); if (!canceled) { throw new RuntimeException("Failed to receive: " + currentFileName + " received:" + received + " bytes, expected: " + currentNetworkPacket.getPayloadSize() + " bytes"); } } else { publishFile(fileDocument, received); } } else { //TODO: Only set progress to 100 if this is the only file/packet to send setProgress(100); publishFile(fileDocument, 0); } boolean listIsEmpty; synchronized (lock) { networkPacketList.remove(0); listIsEmpty = networkPacketList.isEmpty(); } if (listIsEmpty && !canceled) { try { Thread.sleep(1000); } 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(); } } isRunning = false; if (canceled) { receiveNotification.cancel(); return; } int numFiles; synchronized (lock) { numFiles = totalNumFiles; } if (numFiles == 1 && currentNetworkPacket.has("open")) { receiveNotification.cancel(); openFile(fileDocument); } else { //Update the notification and allow to open the file from it receiveNotification.setFinished(getDevice().getContext().getResources().getQuantityString(R.plurals.received_files_title, numFiles, getDevice().getName(), numFiles)); if (totalNumFiles == 1 && fileDocument != null) { receiveNotification.setURI(fileDocument.getUri(), fileDocument.getType(), fileDocument.getName()); } receiveNotification.show(); } reportResult(null); } catch (ActivityNotFoundException e) { receiveNotification.setFinished(getDevice().getContext().getString(R.string.no_app_for_opening)); receiveNotification.show(); } catch (Exception e) { isRunning = false; Log.e("Shareplugin", "Error receiving file", e); int failedFiles; synchronized (lock) { failedFiles = (totalNumFiles - currentFileNum + 1); } receiveNotification.setFinished(getDevice().getContext().getResources().getQuantityString(R.plurals.received_files_fail_title, failedFiles, getDevice().getName(), failedFiles, totalNumFiles)); receiveNotification.show(); reportError(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 ReceiveNotification::setURI) 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(getDevice().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(getDevice().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 && !canceled) { 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) { receiveNotification.setProgress(progress, getDevice().getContext().getResources() .getQuantityString(R.plurals.incoming_files_text, totalNumFiles, currentFileName, currentFileNum, totalNumFiles)); } receiveNotification.show(); } private void publishFile(DocumentFile fileDocument, long size) { if (!ShareSettingsFragment.isCustomDestinationEnabled(getDevice().getContext())) { Log.i("SharePlugin", "Adding to downloads"); 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(getDevice().getContext(), fileDocument.getUri()); } } private void openFile(DocumentFile fileDocument) { String mimeType = FilesHelper.getMimeTypeFromFile(fileDocument.getName()); 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(getDevice().getContext(), "org.kde.kdeconnect_tp.fileprovider", file); intent.setDataAndType(contentUri, mimeType); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK); } else { intent.setDataAndType(fileDocument.getUri(), mimeType); } getDevice().getContext().startActivity(intent); } } diff --git a/src/org/kde/kdeconnect/Plugins/SharePlugin/ReceiveNotification.java b/src/org/kde/kdeconnect/Plugins/SharePlugin/ReceiveNotification.java index 848ec315..19588f34 100644 --- a/src/org/kde/kdeconnect/Plugins/SharePlugin/ReceiveNotification.java +++ b/src/org/kde/kdeconnect/Plugins/SharePlugin/ReceiveNotification.java @@ -1,211 +1,205 @@ package org.kde.kdeconnect.Plugins.SharePlugin; /* * Copyright 2017 Nicolas Fella * * 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 . */ import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.net.Uri; import android.os.Build; import android.preference.PreferenceManager; import org.kde.kdeconnect.Device; import org.kde.kdeconnect.Helpers.NotificationHelper; import org.kde.kdeconnect_tp.R; import java.io.File; import java.io.IOException; import java.io.InputStream; import androidx.core.app.NotificationCompat; import androidx.core.content.FileProvider; class ReceiveNotification { private final NotificationManager notificationManager; private final int notificationId; private NotificationCompat.Builder builder; private final Device device; - private long currentJobId; + private long jobId; //https://documentation.onesignal.com/docs/android-customizations#section-big-picture private static final int bigImageWidth = 1440; private static final int bigImageHeight = 720; - public ReceiveNotification(Device device) { + public ReceiveNotification(Device device, long jobId) { this.device = device; + this.jobId = jobId; notificationId = (int) System.currentTimeMillis(); notificationManager = (NotificationManager) device.getContext().getSystemService(Context.NOTIFICATION_SERVICE); builder = new NotificationCompat.Builder(device.getContext(), NotificationHelper.Channels.FILETRANSFER) .setSmallIcon(android.R.drawable.stat_sys_download) .setAutoCancel(true) .setOngoing(true) .setProgress(100, 0, true); + addCancelAction(); } public void show() { NotificationHelper.notifyCompat(notificationManager, notificationId, builder.build()); } public void cancel() { notificationManager.cancel(notificationId); } - public void addCancelAction(long jobId) { - builder.mActions.clear(); + public void addCancelAction() { - 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; - } - 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); builder.setStyle(new NotificationCompat.BigTextStyle().bigText(progressMessage)); } public void setFinished(String message) { builder = new NotificationCompat.Builder(device.getContext(), NotificationHelper.Channels.DEFAULT); builder.setContentTitle(message) .setTicker(message) .setSmallIcon(android.R.drawable.stat_sys_download_done) .setAutoCancel(true) .setOngoing(false); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(device.getContext()); if (prefs.getBoolean("share_notification_preference", true)) { builder.setDefaults(Notification.DEFAULT_ALL); } } 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. * * TODO: implement our own content provider (instead of support-v4's FileProvider). It should: * - Proxy to real files (in case of the default download folder) * - Proxy to the underlying content uri (in case of a custom download folder) */ //If it's an image, try to show it in the notification if (mimeType.startsWith("image/")) { //https://developer.android.com/topic/performance/graphics/load-bitmap final BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; try (InputStream decodeBoundsInputStream = device.getContext().getContentResolver().openInputStream(destinationUri); InputStream decodeInputStream = device.getContext().getContentResolver().openInputStream(destinationUri)) { BitmapFactory.decodeStream(decodeBoundsInputStream, null, options); options.inJustDecodeBounds = false; options.inSampleSize = calculateInSampleSize(options, bigImageWidth, bigImageHeight); Bitmap image = BitmapFactory.decodeStream(decodeInputStream, null, options); if (image != null) { builder.setLargeIcon(image); builder.setStyle(new NotificationCompat.BigPictureStyle() .bigPicture(image)); } } catch (IOException ignored) { } } if (!"file".equals(destinationUri.getScheme())) { return; } Intent intent = new Intent(Intent.ACTION_VIEW); Intent shareIntent = new Intent(Intent.ACTION_SEND); shareIntent.setType(mimeType); 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); shareIntent.putExtra(Intent.EXTRA_STREAM, contentUri); } else { intent.setDataAndType(destinationUri, mimeType); shareIntent.putExtra(Intent.EXTRA_STREAM, destinationUri); } intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); shareIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); PendingIntent resultPendingIntent = PendingIntent.getActivity( device.getContext(), 0, intent, PendingIntent.FLAG_UPDATE_CURRENT ); builder.setContentText(device.getContext().getResources().getString(R.string.received_file_text, filename)) .setContentIntent(resultPendingIntent); shareIntent = Intent.createChooser(shareIntent, device.getContext().getString(R.string.share_received_file, destinationUri.getLastPathSegment())); PendingIntent sharePendingIntent = PendingIntent.getActivity(device.getContext(), (int) System.currentTimeMillis(), shareIntent, PendingIntent.FLAG_UPDATE_CURRENT); NotificationCompat.Action.Builder shareAction = new NotificationCompat.Action.Builder( R.drawable.ic_share_white, device.getContext().getString(R.string.share), sharePendingIntent); builder.addAction(shareAction.build()); } private int calculateInSampleSize(BitmapFactory.Options options, int targetWidth, int targetHeight) { int inSampleSize = 1; if (options.outHeight > targetHeight || options.outWidth > targetWidth) { final int halfHeight = options.outHeight / 2; final int halfWidth = options.outWidth / 2; while ((halfHeight / inSampleSize) >= targetHeight && (halfWidth / inSampleSize) >= targetWidth) { inSampleSize *= 2; } } return inSampleSize; } }